Files
AutoMood/static/index.html
jerem 02180f1c7b 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)
2026-06-13 15:47:49 +02:00

687 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AutoMood — Prospection</title>
<style>
:root { --accent: #5b4dbf; --fond: #f5f4fa; --bordure: #ddd; }
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 0; background: var(--fond); color: #222; }
header { background: var(--accent); color: #fff; padding: 14px 24px; }
header h1 { margin: 0; font-size: 20px; }
main { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
section { background: #fff; border-radius: 10px; padding: 20px; margin-bottom: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
h2 { margin-top: 0; font-size: 16px; }
.ligne-url { display: flex; gap: 8px; }
.ligne-url input { flex: 1; }
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; }
#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, .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; }
.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; 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>
<body>
<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 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">
<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">
<input id="url" type="url" placeholder="https://www.facebook.com/nom-du-lieu" autofocus>
<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" class="message"></div>
<form id="formulaire">
<div class="grille" id="grille-champs"></div>
<div class="actions">
<button type="submit">Ajouter au fichier</button>
<button type="button" class="secondaire" id="annuler">Annuler</button>
</div>
</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 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>
<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…">
<select id="filtre-statut"><option value="">Tous les statuts</option></select>
</div>
<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","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 = {
"À 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, cible = "message") {
const m = $(cible);
m.textContent = texte;
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-relance").value = config.delai_relance_jours ?? "";
$("cfg-message").value = config.modele_message || "";
afficherRelances();
}
$("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,
delai_relance_jours: $("cfg-relance").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");
afficherRelances();
} 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 ---
// 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;
}
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 || "";
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" + (col === "Notes" ? " large" : "");
const label = document.createElement("label");
label.textContent = col;
div.append(label, champControle(col, valeurs[col]));
grille.append(div);
}
}
function construireFormulaire(valeurs) {
construireGrilleChamps($("grille-champs"), valeurs);
$("formulaire").style.display = "block";
}
$("connexion").addEventListener("click", async () => {
const bouton = $("connexion");
bouton.disabled = true;
$("analyser").disabled = true;
bouton.innerHTML = '<span class="spinner"></span>En attente de votre connexion…';
message("Une fenêtre Chromium s'ouvre sur la page de connexion Facebook. Connectez-vous tranquillement (vous avez 5 minutes), la fenêtre se fermera toute seule.", "info");
try {
const rep = await fetch("/api/login", { method: "POST" });
const donnees = await rep.json();
message(rep.ok ? "Connexion Facebook enregistrée ✔ Vous pouvez analyser des pages." : (donnees.message || "Échec de la connexion."), rep.ok ? "info" : "erreur");
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur");
} finally {
bouton.disabled = false;
$("analyser").disabled = false;
bouton.textContent = "🔑 Connexion Facebook";
}
});
$("analyser").addEventListener("click", async () => {
const url = $("url").value.trim();
if (!url) { message("Collez d'abord un lien Facebook.", "erreur"); return; }
const bouton = $("analyser");
bouton.disabled = true;
bouton.innerHTML = '<span class="spinner"></span>Analyse…';
message("Analyse en cours, une fenêtre Chromium s'ouvre quelques secondes…", "info");
try {
const rep = await fetch("/api/scrape", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const donnees = await rep.json();
if (!rep.ok) { message(donnees.message || "Erreur lors de l'analyse.", "erreur"); return; }
message("Champs pré-remplis : vérifiez et corrigez avant d'ajouter.", "info");
construireFormulaire(donnees);
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur");
} 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, textarea")) ligne[ctrl.name] = ctrl.value;
const rep = await fetch("/api/prospects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ligne),
});
if (rep.ok) {
$("formulaire").style.display = "none";
$("url").value = "";
message("Prospect ajouté ✔", "info");
chargerListe();
} else {
message("Échec de l'ajout.", "erreur");
}
});
$("annuler").addEventListener("click", () => {
$("formulaire").style.display = "none";
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();
rafraichirLogsSiOuvert();
} 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 = [];
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();
afficherListe();
afficherRelances();
}
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))) &&
(!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) {
liste.innerHTML = `<p class="vide">${prospects.length ? "Aucun prospect ne correspond au filtre." : "Aucun prospect pour le moment."}</p>`;
return;
}
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 = `
<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>
${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>`;
const boutons = document.createElement("div");
boutons.className = "carte-boutons";
if (p["Lien Facebook"]) {
const fb = document.createElement("button");
fb.className = "secondaire";
fb.textContent = "Facebook ↗";
fb.addEventListener("click", (e) => { e.stopPropagation(); window.open(p["Lien Facebook"], "_blank"); });
boutons.append(fb);
}
const suppr = document.createElement("button");
suppr.className = "danger";
suppr.textContent = "✕";
suppr.title = "Supprimer";
suppr.addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Supprimer « ${p["Nom du prospect"] || "cette ligne"} » ?`)) return;
await fetch(`/api/prospects/${p.index}`, { method: "DELETE" });
chargerListe();
});
boutons.append(suppr);
tete.append(boutons);
const detail = document.createElement("div");
detail.className = "carte-detail";
detail.style.display = "none";
tete.addEventListener("click", () => {
const ouvert = detail.style.display !== "none";
if (!ouvert && !detail.hasChildNodes()) construireDetail(detail, p);
detail.style.display = ouvert ? "none" : "block";
});
div.append(tete, detail);
return div;
}
function construireDetail(detail, p) {
const grille = document.createElement("div");
grille.className = "grille";
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 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" },
body: JSON.stringify(donnees),
});
if (rep.ok) chargerListe();
});
actions.append(enregistrer);
// 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);
// --- 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();
</script>
</body>
</html>