- Provider chatgpt de LiteLLM (Sign in with ChatGPT, sans clé API) - Module ia.py : login device-code, token local portable (.chatgpt/), génération streaming - Routes /api/message, /api/ia/login, /api/ia/status - UI : boutons Générer/Peaufiner par prospect, connexion ChatGPT + modèle IA dans les Paramètres
788 lines
32 KiB
HTML
788 lines
32 KiB
HTML
<!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="champ" style="margin-top:12px">
|
||
<label>Modèle IA (génération de messages)</label>
|
||
<input id="cfg-ia-modele" type="text" placeholder="chatgpt/gpt-5.4">
|
||
<p class="indice">Utilisé via votre abonnement ChatGPT (sans clé API). Ex. <code>chatgpt/gpt-5.4</code>, ou <code>chatgpt/gpt-5.3-instant</code> (plus rapide).</p>
|
||
</div>
|
||
<div class="champ" style="margin-top:12px">
|
||
<label>Connexion ChatGPT (pour la génération par IA)</label>
|
||
<div id="ia-statut" class="indice">Vérification…</div>
|
||
<div class="actions">
|
||
<button type="button" class="secondaire" id="ia-connexion">🔗 Se connecter à ChatGPT</button>
|
||
</div>
|
||
<div id="ia-code" class="banniere-relance" style="display:none"></div>
|
||
</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 https://www.facebook.com/lieu-2 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 || "";
|
||
$("cfg-ia-modele").value = config.ia_modele || "";
|
||
afficherRelances();
|
||
rafraichirStatutIA();
|
||
}
|
||
|
||
$("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,
|
||
ia_modele: $("cfg-ia-modele").value.trim(),
|
||
};
|
||
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");
|
||
}
|
||
});
|
||
|
||
// --- Connexion ChatGPT (génération IA) ---
|
||
|
||
let iaPolling = null;
|
||
|
||
async function rafraichirStatutIA() {
|
||
let s;
|
||
try { s = await (await fetch("/api/ia/status")).json(); } catch { return; }
|
||
const statut = $("ia-statut"), bouton = $("ia-connexion"), code = $("ia-code");
|
||
if (s.connecte) {
|
||
statut.textContent = "✅ Connecté à ChatGPT.";
|
||
bouton.textContent = "🔄 Se reconnecter";
|
||
code.style.display = "none";
|
||
} else if (s.en_cours) {
|
||
statut.textContent = "⏳ En attente de validation dans le navigateur…";
|
||
code.style.display = "block";
|
||
code.innerHTML = `Ouvrez <a href="${echap(s.verification_url)}" target="_blank" rel="noopener">${echap(s.verification_url)}</a> ` +
|
||
`puis saisissez le code : <strong style="font-size:18px">${echap(s.user_code)}</strong>`;
|
||
} else {
|
||
statut.textContent = s.erreur ? `❌ ${s.erreur}` : "Non connecté à ChatGPT.";
|
||
bouton.textContent = "🔗 Se connecter à ChatGPT";
|
||
code.style.display = "none";
|
||
}
|
||
return s;
|
||
}
|
||
|
||
$("ia-connexion").addEventListener("click", async () => {
|
||
$("ia-connexion").disabled = true;
|
||
try {
|
||
const r = await (await fetch("/api/ia/login", { method: "POST" })).json();
|
||
if (r.verification_url) window.open(r.verification_url, "_blank", "noopener");
|
||
} catch {}
|
||
$("ia-connexion").disabled = false;
|
||
await rafraichirStatutIA();
|
||
clearInterval(iaPolling);
|
||
iaPolling = setInterval(async () => {
|
||
const s = await rafraichirStatutIA();
|
||
if (s && !s.en_cours) clearInterval(iaPolling); // connecté, échec ou expiration
|
||
}, 3000);
|
||
});
|
||
|
||
// 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 — zone éditable partagée (modèle statique ou message généré par IA)
|
||
const zoneIA = document.createElement("textarea");
|
||
zoneIA.className = "ia-zone";
|
||
zoneIA.rows = 6;
|
||
zoneIA.style.cssText = "width:100%;margin-top:8px;display:none";
|
||
const iaInfo = document.createElement("div");
|
||
iaInfo.className = "indice";
|
||
|
||
// Texte courant : message généré/édité s'il existe, sinon le modèle statique.
|
||
const texteContact = () => (zoneIA.value.trim() ? zoneIA.value : messagePour(p));
|
||
|
||
const copier = document.createElement("button");
|
||
copier.className = "secondaire";
|
||
copier.textContent = "📋 Copier le message";
|
||
copier.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(texteContact());
|
||
copier.textContent = "✔ Copié";
|
||
setTimeout(() => { copier.textContent = "📋 Copier le message"; }, 1500);
|
||
} catch {
|
||
copier.textContent = "Échec de la copie";
|
||
}
|
||
});
|
||
|
||
async function genererIA(mode, bouton) {
|
||
const ancien = bouton.textContent;
|
||
bouton.disabled = true;
|
||
bouton.innerHTML = '<span class="spinner sombre"></span> Génération…';
|
||
iaInfo.textContent = "";
|
||
try {
|
||
const rep = await fetch("/api/message", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prospect: p, mode }),
|
||
});
|
||
const d = await rep.json();
|
||
if (!rep.ok) { iaInfo.innerHTML = `<span style="color:#b03a2e">${echap(d.message || "Échec de la génération.")}</span>`; return; }
|
||
zoneIA.value = d.message || "";
|
||
zoneIA.style.display = "block";
|
||
iaInfo.textContent = "Message généré — éditable, puis « Copier le message ».";
|
||
} catch {
|
||
iaInfo.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
|
||
} finally {
|
||
bouton.disabled = false;
|
||
bouton.textContent = ancien;
|
||
}
|
||
}
|
||
|
||
const genererBtn = document.createElement("button");
|
||
genererBtn.className = "secondaire";
|
||
genererBtn.textContent = "✨ Générer (IA)";
|
||
genererBtn.addEventListener("click", () => genererIA("generer", genererBtn));
|
||
|
||
const peaufinerBtn = document.createElement("button");
|
||
peaufinerBtn.className = "secondaire";
|
||
peaufinerBtn.textContent = "✨ Peaufiner (IA)";
|
||
peaufinerBtn.addEventListener("click", () => genererIA("peaufiner", peaufinerBtn));
|
||
|
||
actions.append(copier, genererBtn, peaufinerBtn);
|
||
|
||
// 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, iaInfo, zoneIA, 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>
|