Ajout suivi prospection : statut, import en masse, message type, trajet+péage

- Statut de prospection (colonne CSV) avec badge coloré et filtre
- Import en masse de liens Facebook (streaming, dédoublonnage)
- Modèle de message de contact configurable + copie en un clic
- Estimation distance/carburant/péage via OpenStreetMap (Nominatim + OSRM)
- Section Paramètres + config.json (non versionné)
This commit is contained in:
jerem
2026-06-13 15:28:25 +02:00
parent 1e57e56643
commit 1cf427a0f2
6 changed files with 671 additions and 120 deletions

View File

@@ -15,34 +15,44 @@
h2 { margin-top: 0; font-size: 16px; }
.ligne-url { display: flex; gap: 8px; }
.ligne-url input { flex: 1; }
input, button { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); }
input:focus { outline: 2px solid var(--accent); border-color: transparent; }
input, button, select, textarea { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); }
textarea { width: 100%; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
button { background: var(--accent); color: #fff; border: none; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
button.secondaire { background: #eee; color: #222; }
button.danger { background: #c0392b; }
#message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; }
#message.erreur { display: block; background: #fdecea; color: #b03a2e; }
#message.info { display: block; background: #eaf2fd; color: #1a5276; }
.message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; }
.message.erreur { display: block; background: #fdecea; color: #b03a2e; }
.message.info { display: block; background: #eaf2fd; color: #1a5276; }
#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 { width: 100%; }
.actions { margin-top: 14px; display: flex; gap: 8px; }
.champ input, .champ select { width: 100%; }
.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; }
.carte-tete:hover { background: #faf9fd; }
.carte-titre { display: flex; align-items: center; gap: 8px; min-width: 260px; flex: 1; }
.carte-titre strong { font-size: 14px; }
.badge { background: var(--accent); color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; }
.badge-statut { color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; }
.carte-infos { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13px; align-items: center; }
.sous { color: #666; font-size: 12px; }
.carte-boutons { display: flex; gap: 6px; margin-left: auto; }
.carte-boutons button { padding: 4px 9px; font-size: 12px; }
.carte-detail { padding: 12px; border-top: 1px solid var(--bordure); background: #fbfaff; }
.lien-bouton { display: inline-block; text-decoration: none; padding: 8px 10px; border-radius: 6px; background: #eee; color: #222; font-size: 13px; }
.trajet-resu { margin-top: 8px; font-size: 14px; }
.vide { color: #999; font-style: italic; }
.barre-liste { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.barre-liste { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.filtres { display: flex; gap: 8px; margin-bottom: 12px; }
.filtres input { flex: 1; }
details.params summary { cursor: pointer; font-weight: 600; font-size: 16px; }
.indice { color: #888; font-size: 12px; margin: 2px 0 10px; }
.ligne-progres { padding: 4px 0; font-size: 13px; border-bottom: 1px solid #f0eef8; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff; border-top-color: transparent; border-radius: 50%; animation: tourne .8s linear infinite; vertical-align: -2px; margin-right: 6px; }
.spinner.sombre { border-color: var(--accent); border-top-color: transparent; }
@keyframes tourne { to { transform: rotate(360deg); } }
</style>
</head>
@@ -50,6 +60,40 @@
<header><h1>🎸 AutoMood — Prospection de lieux de concert</h1></header>
<main>
<section>
<details class="params">
<summary>⚙️ Paramètres</summary>
<div class="grille" style="margin-top:14px">
<div class="champ">
<label>Adresse de départ (pour le calcul des trajets)</label>
<input id="cfg-adresse" placeholder="12 rue de la Paix, 44000 Nantes">
</div>
<div class="champ">
<label>Consommation (L / 100 km)</label>
<input id="cfg-conso" type="number" step="0.1" min="0" placeholder="6.5">
</div>
<div class="champ">
<label>Prix du carburant (€ / L)</label>
<input id="cfg-prix" type="number" step="0.01" min="0" placeholder="1.90">
</div>
<div class="champ">
<label>Tarif péage (€ / km d'autoroute)</label>
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
</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">
<label>Modèle de message de prise de contact</label>
<textarea id="cfg-message" rows="6"></textarea>
<p class="indice">Variables disponibles : <code>{nom}</code> (nom du lieu), <code>{ville}</code> (devient « à Ville », ou rien), <code>{type}</code>.</p>
</div>
<div class="actions">
<button id="cfg-enregistrer">Enregistrer les paramètres</button>
</div>
<div id="message-config" class="message"></div>
</details>
</section>
<section>
<h2>Nouveau prospect</h2>
<div class="ligne-url">
@@ -57,7 +101,7 @@
<button id="analyser">Analyser</button>
<button id="connexion" class="secondaire" title="À faire une seule fois : la session est mémorisée">🔑 Connexion Facebook</button>
</div>
<div id="message"></div>
<div id="message" class="message"></div>
<form id="formulaire">
<div class="grille" id="grille-champs"></div>
<div class="actions">
@@ -67,45 +111,144 @@
</form>
</section>
<section>
<h2>Import en masse</h2>
<p class="indice">Collez plusieurs liens Facebook (un par ligne). Chaque lieu est analysé puis ajouté automatiquement ; les doublons déjà présents sont ignorés.</p>
<textarea id="urls-lot" rows="5" placeholder="https://www.facebook.com/lieu-1&#10;https://www.facebook.com/lieu-2&#10;https://www.facebook.com/lieu-3"></textarea>
<div class="actions">
<button id="importer">Importer la liste</button>
</div>
<div id="message-lot" class="message"></div>
<div id="progres-lot"></div>
</section>
<section>
<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>
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…" style="width:100%;margin-bottom:12px">
<div class="filtres">
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
<select id="filtre-statut"><option value="">Tous les statuts</option></select>
</div>
<div id="liste"></div>
</section>
</main>
<script>
const COLONNES = ["Nom du prospect","Département","Ville","Code Postal","Adresse","Date d'ajout",
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"];
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
const STATUTS = {
"À contacter": "#7f8c8d",
"Contacté": "#2980b9",
"En discussion": "#f39c12",
"Concert programmé": "#27ae60",
"Sans réponse": "#95a5a6",
"Refusé": "#c0392b",
};
const STATUT_DEFAUT = "À contacter";
const $ = id => document.getElementById(id);
function message(texte, classe) {
const m = $("message");
function message(texte, classe, cible = "message") {
const m = $(cible);
m.textContent = texte;
m.className = classe || "";
m.className = "message " + (classe || "");
}
// Statut effectif : valeur enregistrée, ou déduit pour les anciennes lignes.
function statutDe(p) {
const s = (p["Statut"] || "").trim();
if (s) return s;
return (p["Date de contact"] || "").trim() ? "Contacté" : STATUT_DEFAUT;
}
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
// --- Paramètres ---
let config = {};
async function chargerConfig() {
try {
config = await (await fetch("/api/config")).json();
} catch { config = {}; }
$("cfg-adresse").value = config.adresse_depart || "";
$("cfg-conso").value = config.conso_l_100km ?? "";
$("cfg-prix").value = config.prix_carburant ?? "";
$("cfg-peage").value = config.cout_peage_km ?? "";
$("cfg-message").value = config.modele_message || "";
}
$("cfg-enregistrer").addEventListener("click", async () => {
const corps = {
adresse_depart: $("cfg-adresse").value.trim(),
conso_l_100km: $("cfg-conso").value,
prix_carburant: $("cfg-prix").value,
cout_peage_km: $("cfg-peage").value,
modele_message: $("cfg-message").value,
};
const rep = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps),
});
if (rep.ok) {
config = (await rep.json()).config;
message("Paramètres enregistrés ✔", "info", "message-config");
} else {
message("Échec de l'enregistrement.", "erreur", "message-config");
}
});
// Construit le message de contact à partir du modèle et du prospect.
function messagePour(p) {
const ville = (p["Ville"] || "").trim();
return (config.modele_message || "")
.replaceAll("{nom}", p["Nom du prospect"] || "")
.replaceAll("{ville}", ville ? (" à " + ville) : "")
.replaceAll("{type}", (p["Type"] || "").toLowerCase());
}
// --- Formulaire de nouveau prospect ---
function construireFormulaire(valeurs) {
const grille = $("grille-champs");
// Champ texte, ou menu déroulant pour le statut.
function champControle(col, valeur) {
if (col === "Statut") {
const sel = document.createElement("select");
sel.name = col;
const courant = (valeur || "").trim() || STATUT_DEFAUT;
for (const s of Object.keys(STATUTS)) {
const opt = document.createElement("option");
opt.value = s; opt.textContent = s;
if (s === courant) opt.selected = true;
sel.append(opt);
}
return sel;
}
const input = document.createElement("input");
input.name = col;
input.value = valeur || "";
if (col === "Date de contact") input.placeholder = "à remplir plus tard";
return input;
}
function construireGrilleChamps(grille, valeurs) {
grille.innerHTML = "";
for (const col of COLONNES) {
const div = document.createElement("div");
div.className = "champ";
const label = document.createElement("label");
label.textContent = col;
const input = document.createElement("input");
input.name = col;
input.value = valeurs[col] || "";
if (col === "Date de contact") input.placeholder = "à remplir plus tard";
div.append(label, input);
div.append(label, champControle(col, valeurs[col]));
grille.append(div);
}
}
function construireFormulaire(valeurs) {
construireGrilleChamps($("grille-champs"), valeurs);
$("formulaire").style.display = "block";
}
@@ -156,7 +299,7 @@ $("analyser").addEventListener("click", async () => {
$("formulaire").addEventListener("submit", async (e) => {
e.preventDefault();
const ligne = {};
for (const input of $("grille-champs").querySelectorAll("input")) ligne[input.name] = input.value;
for (const ctrl of $("grille-champs").querySelectorAll("input, select")) ligne[ctrl.name] = ctrl.value;
const rep = await fetch("/api/prospects", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -177,11 +320,77 @@ $("annuler").addEventListener("click", () => {
message("", "");
});
// --- Import en masse ---
$("importer").addEventListener("click", async () => {
const urls = $("urls-lot").value.split("\n").map(s => s.trim()).filter(Boolean);
if (!urls.length) { message("Collez au moins un lien.", "erreur", "message-lot"); return; }
const bouton = $("importer");
bouton.disabled = true;
bouton.innerHTML = '<span class="spinner"></span>Import en cours…';
message(`Analyse de ${urls.length} lien(s)… une fenêtre Chromium reste ouverte pendant l'opération.`, "info", "message-lot");
$("progres-lot").innerHTML = "";
const compte = { ajoute: 0, doublon: 0, erreur: 0 };
try {
const rep = await fetch("/api/scrape-lot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
});
if (!rep.ok) {
const d = await rep.json().catch(() => ({}));
message(d.message || "Échec de l'import.", "erreur", "message-lot");
return;
}
const lecteur = rep.body.getReader();
const decodeur = new TextDecoder();
let tampon = "";
while (true) {
const { value, done } = await lecteur.read();
if (done) break;
tampon += decodeur.decode(value, { stream: true });
let coupure;
while ((coupure = tampon.indexOf("\n")) >= 0) {
const ligne = tampon.slice(0, coupure).trim();
tampon = tampon.slice(coupure + 1);
if (ligne) afficherProgresLot(JSON.parse(ligne), compte);
}
}
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
chargerListe();
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
} finally {
bouton.disabled = false;
bouton.textContent = "Importer la liste";
}
});
function afficherProgresLot(res, compte) {
if (compte[res.statut] !== undefined) compte[res.statut]++;
const icone = { ajoute: "✅", doublon: "⏭️", erreur: "❌" }[res.statut] || "•";
const intitule = res.nom ? echap(res.nom) : echap(res.url || "");
const detail = res.statut === "doublon" ? " — déjà présent"
: res.statut === "erreur" ? `${echap(res.message || "échec")}` : "";
const div = document.createElement("div");
div.className = "ligne-progres";
div.innerHTML = `${icone} <strong>${intitule}</strong><span class="sous">${detail}</span>`;
$("progres-lot").append(div);
}
// --- Liste des prospects en fiches ---
let prospects = [];
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function remplirFiltreStatut() {
const sel = $("filtre-statut");
if (sel.options.length > 1) return; // déjà rempli
for (const s of Object.keys(STATUTS)) {
const opt = document.createElement("option");
opt.value = s; opt.textContent = s;
sel.append(opt);
}
}
async function chargerListe() {
prospects = await (await fetch("/api/prospects")).json();
@@ -190,9 +399,12 @@ async function chargerListe() {
function afficherListe() {
const filtre = $("recherche").value.trim().toLowerCase();
const statutVoulu = $("filtre-statut").value;
const visibles = prospects.filter(p =>
!filtre || COLONNES.some(c => (p[c] || "").toLowerCase().includes(filtre)));
$("compteur").textContent = filtre ? `(${visibles.length}/${prospects.length})` : `(${prospects.length})`;
(!filtre || COLONNES.some(c => (p[c] || "").toLowerCase().includes(filtre))) &&
(!statutVoulu || statutDe(p) === statutVoulu));
const actif = filtre || statutVoulu;
$("compteur").textContent = actif ? `(${visibles.length}/${prospects.length})` : `(${prospects.length})`;
const liste = $("liste");
liste.innerHTML = "";
if (!visibles.length) {
@@ -209,19 +421,21 @@ function carte(p) {
const tete = document.createElement("div");
tete.className = "carte-tete";
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
const contact = p["Date de contact"]
? `✅ Contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}`
: "⏳ À contacter";
const st = statutDe(p);
const couleur = STATUTS[st] || "#7f8c8d";
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 = `
<div class="carte-titre">
<strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong>
${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""}
</div>
<div class="carte-infos">
<span class="badge-statut" style="background:${couleur}">${echap(st)}</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>` : ""}
<span class="sous">${contact}</span>
${contacte}
</div>`;
const boutons = document.createElement("div");
@@ -263,24 +477,16 @@ function carte(p) {
function construireDetail(detail, p) {
const grille = document.createElement("div");
grille.className = "grille";
for (const col of COLONNES) {
const champ = document.createElement("div");
champ.className = "champ";
const label = document.createElement("label");
label.textContent = col;
const input = document.createElement("input");
input.name = col;
input.value = p[col] || "";
champ.append(label, input);
grille.append(champ);
}
construireGrilleChamps(grille, p);
const actions = document.createElement("div");
actions.className = "actions";
const enregistrer = document.createElement("button");
enregistrer.textContent = "Enregistrer";
enregistrer.addEventListener("click", async () => {
const donnees = {};
for (const input of grille.querySelectorAll("input")) donnees[input.name] = input.value;
for (const ctrl of grille.querySelectorAll("input, select")) donnees[ctrl.name] = ctrl.value;
const rep = await fetch(`/api/prospects/${p.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -289,11 +495,61 @@ function construireDetail(detail, p) {
if (rep.ok) chargerListe();
});
actions.append(enregistrer);
detail.append(grille, actions);
// Prise de contact
const copier = document.createElement("button");
copier.className = "secondaire";
copier.textContent = "📋 Copier le message";
copier.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(messagePour(p));
copier.textContent = "✔ Copié";
setTimeout(() => { copier.textContent = "📋 Copier le message"; }, 1500);
} catch {
copier.textContent = "Échec de la copie";
}
});
actions.append(copier);
// Trajet + carburant
const trajetBtn = document.createElement("button");
trajetBtn.className = "secondaire";
trajetBtn.textContent = "🚗 Calculer le trajet";
const resu = document.createElement("div");
resu.className = "trajet-resu";
trajetBtn.addEventListener("click", async () => {
trajetBtn.disabled = true;
resu.innerHTML = '<span class="spinner sombre"></span> Calcul en cours…';
try {
const rep = await fetch(`/api/distance/${p.index}`);
const d = await rep.json();
if (!rep.ok) { resu.innerHTML = `<span style="color:#b03a2e">${echap(d.message || "Échec du calcul.")}</span>`; return; }
const detailAller = d.peage_aller > 0
? ` <span class="sous">(carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)</span>` : "";
const detailAR = d.peage_aller_retour > 0
? ` <span class="sous">(carburant ${d.carburant_aller_retour} € + péage ${d.peage_aller_retour} €)</span>` : "";
resu.innerHTML =
`🚗 <strong>${d.distance_km} km</strong> (~${d.duree_min} min` +
(d.km_peage > 0 ? `, dont ${d.km_peage} km d'autoroute` : "") + `)<br>` +
`Aller : <strong>${d.cout_aller} €</strong>${detailAller}<br>` +
`Aller-retour : <strong>${d.cout_aller_retour} €</strong>${detailAR} ` +
`<span class="sous">(${d.distance_aller_retour_km} km)</span>`;
} catch {
resu.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
} finally {
trajetBtn.disabled = false;
}
});
actions.append(trajetBtn);
detail.append(grille, actions, resu);
}
$("recherche").addEventListener("input", afficherListe);
$("filtre-statut").addEventListener("change", afficherListe);
remplirFiltreStatut();
chargerConfig();
chargerListe();
</script>
</body>