Files
AutoMood/static/index.html
jerem 018add739a Infos du groupe dans les réglages, injectées au prompt IA
Nouveaux champs (nom, style, description, lien) en réglages, transmis au
prompt système de génération de messages dans les deux modes (générer et
peaufiner). La consigne de format reste en dernière position, non éditable.
Bloc omis si aucun champ rempli : prompt identique à l'ancien.
2026-06-13 23:33:30 +02:00

1104 lines
47 KiB
HTML
Raw Permalink 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 {
/* Couleurs */
--accent: #5b4dbf;
--accent-fonce: #473aa6;
--accent-doux: #efedfa;
--texte: #1d1b2e;
--texte-doux: #5a5870;
--texte-faible: #8a88a0;
--bordure: #e4e2ee;
--surface: #fff;
--fond: #f5f4fa;
--relance: #e67e22;
/* Espacements */
--e1: 4px; --e2: 8px; --e3: 12px; --e4: 16px; --e6: 24px; --e8: 32px;
/* Typographie */
--t-xs: 12px; --t-sm: 13px; --t-md: 15px; --t-lg: 18px; --t-xl: 22px;
/* Rayons & ombre */
--r-s: 6px; --r-m: 10px; --r-pill: 99px;
--ombre: 0 1px 3px rgba(29,27,46,.08), 0 6px 18px rgba(29,27,46,.06);
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
margin: 0; background: var(--fond); color: var(--texte);
font-size: var(--t-sm); line-height: 1.5; -webkit-font-smoothing: antialiased;
}
/* En-tête + onglets */
header { background: var(--surface); border-bottom: 1px solid var(--bordure); position: sticky; top: 0; z-index: 20; }
.header-inner { max-width: 1100px; margin: 0 auto; padding: var(--e3) var(--e4) 0; }
header h1 { margin: 0 0 var(--e3); font-size: var(--t-lg); display: flex; align-items: baseline; gap: var(--e2); flex-wrap: wrap; }
header h1 .soustitre { font-size: var(--t-xs); color: var(--texte-faible); font-weight: 400; }
.onglets { display: flex; gap: var(--e1); }
.onglets .onglet { background: none; border: none; border-bottom: 2px solid transparent; color: var(--texte-doux); font-size: var(--t-sm); font-weight: 600; padding: var(--e2) var(--e3); cursor: pointer; border-radius: 0; }
.onglets .onglet:hover { background: var(--accent-doux); color: var(--accent); }
.onglets .onglet.actif { background: none; color: var(--accent); border-bottom-color: var(--accent); }
.onglets .onglet:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
main { max-width: 1100px; margin: var(--e6) auto; padding: 0 var(--e4); }
.vue[hidden] { display: none; }
h2 { margin: 0 0 var(--e3); font-size: var(--t-md); }
section.bloc { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--r-m); padding: var(--e4); margin-bottom: var(--e4); box-shadow: var(--ombre); }
/* Champs & boutons */
input, button, select, textarea { font: inherit; }
input, select, textarea { padding: var(--e2) var(--e3); border-radius: var(--r-s); border: 1px solid var(--bordure); background: var(--surface); color: var(--texte); }
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: 1px solid transparent; border-radius: var(--r-s); padding: var(--e2) var(--e3); cursor: pointer; font-weight: 600; }
button:hover { background: var(--accent-fonce); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .5; cursor: wait; }
button.secondaire { background: var(--accent-doux); color: var(--accent); }
button.secondaire:hover { background: #e3e0f6; }
button.danger { background: #c0392b; }
button.danger:hover { background: #a93226; }
.ligne-url { display: flex; gap: var(--e2); flex-wrap: wrap; }
.ligne-url input { flex: 1; min-width: 220px; }
.message { margin: var(--e3) 0 0; padding: var(--e3); border-radius: var(--r-s); display: none; font-size: var(--t-sm); }
.message.erreur { display: block; background: #fdecea; color: #b03a2e; }
.message.info { display: block; background: var(--accent-doux); color: var(--accent-fonce); }
#formulaire { display: none; margin-top: var(--e4); }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--e3); }
.champ label { display: block; font-size: var(--t-xs); color: var(--texte-doux); margin-bottom: var(--e1); font-weight: 600; }
.champ input, .champ select, .champ textarea { width: 100%; }
.grille .champ.large { grid-column: 1 / -1; }
.actions { margin-top: var(--e4); display: flex; gap: var(--e2); flex-wrap: wrap; }
.indice { color: var(--texte-faible); font-size: var(--t-xs); margin: var(--e1) 0 var(--e3); }
code { background: var(--fond); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
/* Bannière relances */
.banniere-relance { background: #fff4e2; border: 1px solid #f0c987; color: #8a5a00; border-radius: var(--r-m); padding: var(--e3) var(--e4); margin-bottom: var(--e4); }
.relance-chips { display: flex; flex-wrap: wrap; gap: var(--e2); margin-top: var(--e2); }
.chip-relance { background: var(--relance); color: #fff; border: none; border-radius: var(--r-pill); padding: var(--e1) var(--e3); font-size: var(--t-xs); cursor: pointer; }
.chip-relance:hover { background: #cf6a16; }
/* Liste */
.barre-liste { display: flex; justify-content: space-between; align-items: center; gap: var(--e3); flex-wrap: wrap; margin-bottom: var(--e3); }
.barre-liste h2 { margin: 0; }
.filtres { display: flex; gap: var(--e2); margin-bottom: var(--e3); flex-wrap: wrap; }
.filtres input { flex: 1; min-width: 160px; }
/* Deux colonnes : liste + détail */
.deux-colonnes { display: grid; grid-template-columns: 320px 1fr; gap: var(--e4); align-items: start; }
.colonne-detail { position: sticky; top: 104px; max-height: calc(100vh - 128px); overflow-y: auto; }
/* Item de liste (compact, sélectionnable) */
.item { display: flex; align-items: center; gap: var(--e2); border: 1px solid var(--bordure); border-radius: var(--r-m); background: var(--surface); padding: var(--e2) var(--e3); margin-bottom: var(--e2); cursor: pointer; transition: border-color .12s, background .12s; }
.item:hover { border-color: var(--accent); }
.item.selectionnee { border-color: var(--accent); background: var(--accent-doux); box-shadow: inset 3px 0 0 var(--accent); }
.item-principal { flex: 1; min-width: 0; }
.item-nom { display: flex; align-items: center; gap: var(--e2); flex-wrap: wrap; }
.item-nom strong { font-size: var(--t-sm); }
.item-lieu { color: var(--texte-doux); font-size: var(--t-xs); margin-top: 2px; }
.carte-titre { display: flex; align-items: center; gap: var(--e2); flex-wrap: wrap; }
.badge { background: var(--accent-doux); color: var(--accent); border-radius: var(--r-pill); padding: 2px 10px; font-size: var(--t-xs); font-weight: 600; white-space: nowrap; }
.badge-statut { color: #fff; border-radius: var(--r-pill); padding: 2px 10px; font-size: var(--t-xs); font-weight: 600; white-space: nowrap; }
.badge-relance { background: var(--relance); color: #fff; border-radius: var(--r-pill); padding: 2px 10px; font-size: var(--t-xs); font-weight: 600; white-space: nowrap; }
.vide { color: var(--texte-faible); font-style: italic; }
.sous { color: var(--texte-doux); font-size: var(--t-xs); }
/* Panneau de détail (colonne droite) */
.detail-vide { color: var(--texte-faible); font-style: italic; text-align: center; padding: var(--e8) var(--e4); border: 1px dashed var(--bordure); border-radius: var(--r-m); background: var(--surface); }
.detail-tete { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--r-m); padding: var(--e4); margin-bottom: var(--e3); display: flex; align-items: flex-start; gap: var(--e2); }
.detail-tete .titre { flex: 1; min-width: 0; }
.detail-tete h2 { margin: 0 0 var(--e2); font-size: var(--t-lg); }
.detail-tete-actions { display: flex; gap: var(--e1); flex-shrink: 0; }
.detail-tete-actions button { padding: var(--e1) var(--e2); font-size: var(--t-xs); }
.detail-section { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--r-m); padding: var(--e4); margin-bottom: var(--e3); }
.detail-section h3 { margin: 0 0 var(--e3); font-size: var(--t-xs); color: var(--texte-doux); text-transform: uppercase; letter-spacing: .04em; }
.sous-groupe { font-size: var(--t-xs); font-weight: 700; color: var(--texte-faible); margin: var(--e4) 0 var(--e2); text-transform: uppercase; letter-spacing: .03em; }
.sous-groupe:first-child { margin-top: 0; }
.trajet-resu { margin-top: var(--e3); font-size: var(--t-sm); line-height: 1.7; }
/* Journal */
.ligne-progres { display: flex; gap: var(--e2); align-items: center; flex-wrap: wrap; padding: var(--e2) 0; font-size: var(--t-sm); border-bottom: 1px solid var(--bordure); }
details summary { cursor: pointer; font-weight: 600; font-size: var(--t-md); }
/* Spinner */
.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); } }
/* ===== Disposition « modale » : liste en grand + détail en fenêtre centrale ===== */
body[data-disposition="modale"] .deux-colonnes { grid-template-columns: 1fr; }
body[data-disposition="modale"] .colonne-detail { display: none; }
body[data-disposition="modale"] #liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--e3); }
body[data-disposition="modale"] .item { margin-bottom: 0; padding: var(--e3) var(--e4); }
body.modale-ouverte { overflow: hidden; }
.modale-fond { position: fixed; inset: 0; z-index: 50; background: rgba(29,27,46,.45); display: flex; align-items: flex-start; justify-content: center; padding: var(--e8) var(--e4); overflow-y: auto; }
.modale-fond[hidden] { display: none; }
.modale { position: relative; width: 100%; max-width: 720px; background: var(--fond); border: 1px solid var(--bordure); border-radius: var(--r-m); box-shadow: 0 12px 48px rgba(29,27,46,.3); padding: var(--e6); }
.modale-fermer { position: absolute; top: var(--e3); right: var(--e3); width: 32px; height: 32px; padding: 0; font-size: 16px; line-height: 1; background: var(--accent-doux); color: var(--accent); }
.modale-fermer:hover { background: #e3e0f6; }
@media (max-width: 640px) {
.modale { padding: var(--e4); }
.modale-fond { padding: var(--e4) var(--e2); }
}
@media (max-width: 860px) {
.deux-colonnes { grid-template-columns: 1fr; }
.colonne-detail { position: static; max-height: none; }
}
@media (max-width: 640px) {
main { margin: var(--e4) auto; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<h1>🎸 AutoMood <span class="soustitre">Prospection de lieux de concert</span></h1>
<nav class="onglets">
<button type="button" class="onglet actif" data-vue="prospects">Prospects</button>
<button type="button" class="onglet" data-vue="ajouter">+ Ajouter</button>
<button type="button" class="onglet" data-vue="reglages">⚙ Réglages</button>
</nav>
</div>
</header>
<main>
<!-- ===== VUE PROSPECTS ===== -->
<div class="vue" id="vue-prospects">
<div id="relances" class="banniere-relance" style="display:none"></div>
<div class="barre-liste">
<h2>Prospects <span id="compteur" class="sous"></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="deux-colonnes">
<div class="colonne-liste">
<div class="filtres">
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type…">
<select id="filtre-statut"><option value="">Tous les statuts</option></select>
</div>
<div id="liste"></div>
</div>
<div class="colonne-detail">
<div id="detail-pane"></div>
</div>
</div>
</div>
<!-- ===== VUE AJOUTER ===== -->
<div class="vue" id="vue-ajouter" hidden>
<section class="bloc">
<h2>Nouveau prospect</h2>
<p class="indice">Collez un lien Facebook, analysez la page, puis vérifiez les champs avant d'ajouter.</p>
<div class="ligne-url">
<input id="url" type="url" placeholder="https://www.facebook.com/nom-du-lieu">
<button id="analyser">Analyser</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 class="bloc">
<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 class="bloc">
<details id="journal-details">
<summary>🩺 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>
</div>
<!-- ===== VUE RÉGLAGES ===== -->
<div class="vue" id="vue-reglages" hidden>
<section class="bloc">
<h2>Affichage</h2>
<div class="champ">
<label>Disposition des prospects</label>
<select id="cfg-disposition">
<option value="colonne">Liste + détail côte à côte (deux colonnes)</option>
<option value="modale">Liste en grand + détail en fenêtre centrale</option>
</select>
<p class="indice">« Liste en grand » étale les prospects sur toute la largeur ; un clic ouvre le détail dans une fenêtre centrale. Préférence enregistrée sur cet appareil.</p>
</div>
</section>
<section class="bloc">
<h2>Trajet & relance</h2>
<div class="grille">
<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>
</section>
<section class="bloc">
<h2>Votre groupe</h2>
<p class="indice">Ces infos sont transmises à l'IA pour personnaliser les messages générés (elles n'apparaissent pas telles quelles : l'IA s'en sert pour rédiger). Laissez vide ce que vous ne voulez pas fournir.</p>
<div class="grille">
<div class="champ">
<label>Nom du groupe</label>
<input id="cfg-groupe-nom" placeholder="Automood">
</div>
<div class="champ">
<label>Style musical</label>
<input id="cfg-groupe-style" placeholder="rock, chanson française, reprises…">
</div>
</div>
<div class="champ" style="margin-top:12px">
<label>Description</label>
<textarea id="cfg-groupe-description" rows="3" placeholder="Trio acoustique, répertoire de reprises et compositions, format adapté aux bars et petites salles…"></textarea>
</div>
<div class="champ" style="margin-top:12px">
<label>Lien (site, réseaux, vidéo…)</label>
<input id="cfg-groupe-lien" type="text" placeholder="https://…">
</div>
</section>
<section class="bloc">
<h2>Message & IA</h2>
<div class="champ">
<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="actions">
<button id="cfg-enregistrer">Enregistrer les paramètres</button>
</div>
<div id="message-config" class="message"></div>
</section>
<section class="bloc">
<h2>Connexions</h2>
<div class="champ">
<label>ChatGPT (pour la génération par IA)</label>
<div id="ia-statut" class="indice">Vérification…</div>
<div class="actions" style="margin-top:0">
<button type="button" class="secondaire" id="ia-connexion">🔗 Se connecter à ChatGPT</button>
</div>
<div id="ia-code" class="banniere-relance" style="display:none;margin-top:12px"></div>
</div>
<div class="champ" style="margin-top:16px">
<label>Facebook (pour le scraping des pages)</label>
<p class="indice">À faire une seule fois : la session est mémorisée.</p>
<div class="actions" style="margin-top:0">
<button type="button" class="secondaire" id="connexion">🔑 Se connecter à Facebook</button>
</div>
<div id="message-connexion" class="message"></div>
</div>
</section>
</div>
</main>
<!-- ===== MODALE DE DÉTAIL (disposition « modale ») ===== -->
<div id="modale-detail" class="modale-fond" hidden>
<div class="modale" role="dialog" aria-modal="true" aria-label="Détail du prospect">
<button type="button" class="modale-fermer" id="modale-fermer" aria-label="Fermer"></button>
<div id="detail-modale-corps"></div>
</div>
</div>
<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"];
// Champs regroupés par thème pour le panneau de détail (couvre toutes les COLONNES).
const GROUPES_CHAMPS = [
["Identité", ["Nom du prospect", "Statut", "Type"]],
["Localisation", ["Adresse", "Code Postal", "Ville", "Département"]],
["Contact", ["Nom de contact", "Téléphone", "Email", "Date de contact"]],
["Suivi", ["Date d'ajout", "Infos du lieu", "Lien Facebook", "Notes"]],
];
// Champs qui occupent toute la largeur de la grille.
const CHAMPS_LARGES = ["Notes", "Infos du lieu", "Adresse"];
// Statuts encore « actifs » pour lesquels une relance a du sens.
const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
// Statut de prospection -> couleur du badge (contrastée pour texte blanc). L'ordre sert au menu déroulant.
const STATUTS = {
"À contacter": "#6b7280",
"Contacté": "#2563eb",
"En discussion": "#d97706",
"Concert programmé": "#16a34a",
"Sans réponse": "#64748b",
"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]));
// --- Navigation par onglets ---
function montrerVue(nom) {
for (const v of document.querySelectorAll(".vue")) v.hidden = (v.id !== "vue-" + nom);
for (const b of document.querySelectorAll(".onglet")) b.classList.toggle("actif", b.dataset.vue === nom);
window.scrollTo(0, 0);
}
document.querySelectorAll(".onglet").forEach(b => b.addEventListener("click", () => montrerVue(b.dataset.vue)));
// --- Paramètres ---
let config = {};
// Disposition de la vue Prospects : "colonne" (liste + détail côte à côte) ou "modale" (liste en grand + fenêtre centrale).
let disposition = localStorage.getItem("disposition") || "colonne";
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 || "";
$("cfg-groupe-nom").value = config.groupe_nom || "";
$("cfg-groupe-style").value = config.groupe_style || "";
$("cfg-groupe-description").value = config.groupe_description || "";
$("cfg-groupe-lien").value = config.groupe_lien || "";
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(),
groupe_nom: $("cfg-groupe-nom").value.trim(),
groupe_style: $("cfg-groupe-style").value.trim(),
groupe_description: $("cfg-groupe-description").value.trim(),
groupe_lien: $("cfg-groupe-lien").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;
}
// Construit un champ (label + contrôle) prêt à insérer dans une grille.
function champBloc(col, valeur) {
const div = document.createElement("div");
div.className = "champ" + (CHAMPS_LARGES.includes(col) ? " large" : "");
const label = document.createElement("label");
label.textContent = col;
div.append(label, champControle(col, valeur));
return div;
}
// Grille à plat de toutes les COLONNES (formulaire d'ajout).
function construireGrilleChamps(grille, valeurs) {
grille.innerHTML = "";
for (const col of COLONNES) grille.append(champBloc(col, valeurs[col]));
}
// Champs regroupés par thème dans un conteneur (panneau de détail).
function construireGrilleGroupee(conteneur, valeurs) {
for (const [titre, cols] of GROUPES_CHAMPS) {
const h = document.createElement("div");
h.className = "sous-groupe";
h.textContent = titre;
conteneur.append(h);
const grille = document.createElement("div");
grille.className = "grille";
for (const col of cols) grille.append(champBloc(col, valeurs[col]));
conteneur.append(grille);
}
}
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", "message-connexion");
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", "message-connexion");
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-connexion");
} finally {
bouton.disabled = false;
$("analyser").disabled = false;
bouton.textContent = "🔑 Se connecter à 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 (colonne gauche) ---
let prospects = [];
let selectionIndex = null; // index du prospect affiché dans le panneau de détail
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();
// Rafraîchit le détail ouvert, ou le vide si le prospect a disparu.
const courant = selectionIndex !== null ? prospects.find(p => p.index === selectionIndex) : null;
if (courant) afficherDetail(courant); else viderDetail();
}
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(itemListe(p));
}
function itemListe(p) {
const div = document.createElement("div");
div.className = "item" + (p.index === selectionIndex ? " selectionnee" : "");
div.dataset.index = p.index;
const st = statutDe(p);
const couleur = STATUTS[st] || "#6b7280";
const relance = joursDeRelance(p);
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
const principal = document.createElement("div");
principal.className = "item-principal";
principal.innerHTML = `
<div class="item-nom">
<strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong>
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
${relance !== null ? `<span class="badge-relance">🔔 ${relance} j</span>` : ""}
</div>
${lieu ? `<div class="item-lieu">📍 ${echap(lieu)}</div>` : ""}`;
div.append(principal);
div.addEventListener("click", () => selectionner(p.index));
return div;
}
// --- 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)));
}
// Sélectionne un prospect (depuis un chip de relance, etc.).
function ouvrirFiche(index) {
montrerVue("prospects");
selectionner(index);
}
// --- Panneau de détail (colonne droite) ---
function selectionner(index) {
selectionIndex = index;
const p = prospects.find(x => x.index === index);
if (!p) { viderDetail(); return; }
for (const el of $("liste").querySelectorAll(".item"))
el.classList.toggle("selectionnee", +el.dataset.index === index);
afficherDetail(p);
if (disposition === "colonne" && window.innerWidth <= 860) $("detail-pane").scrollIntoView({ behavior: "smooth", block: "start" });
}
function viderDetail() {
selectionIndex = null;
$("detail-pane").innerHTML = `<div class="detail-vide">Sélectionnez un prospect dans la liste pour voir et modifier son détail.</div>`;
for (const el of $("liste").querySelectorAll(".item.selectionnee")) el.classList.remove("selectionnee");
if (disposition === "modale") fermerModale();
}
function rendreFiche(container, p) {
container.innerHTML = "";
const st = statutDe(p);
const couleur = STATUTS[st] || "#6b7280";
const tete = document.createElement("div");
tete.className = "detail-tete";
tete.innerHTML = `
<div class="titre">
<h2>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</h2>
<div class="carte-titre">
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""}
</div>
</div>`;
const actions = document.createElement("div");
actions.className = "detail-tete-actions";
if (p["Lien Facebook"]) {
const fb = document.createElement("button");
fb.className = "secondaire";
fb.textContent = "Facebook ↗";
fb.addEventListener("click", () => window.open(p["Lien Facebook"], "_blank"));
actions.append(fb);
}
const suppr = document.createElement("button");
suppr.className = "danger";
suppr.textContent = "Supprimer";
suppr.addEventListener("click", async () => {
if (!confirm(`Supprimer « ${p["Nom du prospect"] || "cette ligne"} » ?`)) return;
await fetch(`/api/prospects/${p.index}`, { method: "DELETE" });
selectionIndex = null;
chargerListe();
});
actions.append(suppr);
tete.append(actions);
container.append(tete);
construireDetail(container, p);
}
// Affiche le détail selon la disposition : colonne latérale (« colonne ») ou fenêtre centrale (« modale »).
function afficherDetail(p) {
if (disposition === "modale") {
rendreFiche($("detail-modale-corps"), p);
ouvrirModale();
} else {
rendreFiche($("detail-pane"), p);
}
}
function ouvrirModale() {
$("modale-detail").hidden = false;
document.body.classList.add("modale-ouverte");
}
function fermerModale() {
$("modale-detail").hidden = true;
document.body.classList.remove("modale-ouverte");
selectionIndex = null;
for (const el of $("liste").querySelectorAll(".item.selectionnee")) el.classList.remove("selectionnee");
}
function sectionDetail(titre) {
const s = document.createElement("div");
s.className = "detail-section";
const h = document.createElement("h3");
h.textContent = titre;
s.append(h);
return s;
}
function construireDetail(corps, p) {
// --- Infos ---
const secInfos = sectionDetail("📍 Infos");
const champsWrap = document.createElement("div");
construireGrilleGroupee(champsWrap, p);
const actionsInfos = document.createElement("div");
actionsInfos.className = "actions";
const enregistrer = document.createElement("button");
enregistrer.textContent = "Enregistrer les modifications";
enregistrer.addEventListener("click", async () => {
const donnees = {};
for (const ctrl of champsWrap.querySelectorAll("input, select, textarea")) donnees[ctrl.name] = ctrl.value;
enregistrer.disabled = true;
const rep = await fetch(`/api/prospects/${p.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(donnees),
});
enregistrer.disabled = false;
if (rep.ok) chargerListe();
});
actionsInfos.append(enregistrer);
secInfos.append(champsWrap, actionsInfos);
// --- Prise de contact ---
const secContact = sectionDetail("✉️ Prise de contact");
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));
const actionsContact = document.createElement("div");
actionsContact.className = "actions";
actionsContact.style.marginTop = "0";
actionsContact.append(copier, genererBtn, peaufinerBtn);
secContact.append(actionsContact, zoneIA, iaInfo);
// --- Trajet & carburant ---
const secTrajet = sectionDetail("🚗 Trajet & carburant");
const trajetBtn = document.createElement("button");
trajetBtn.className = "secondaire";
trajetBtn.textContent = "🚗 Calculer le trajet";
const resu = document.createElement("div");
resu.className = "trajet-resu";
// `silencieux` : affichage auto à l'ouverture (données déjà en cache) — pas de spinner.
async function calculerTrajet(silencieux) {
trajetBtn.disabled = true;
if (!silencieux) 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) {
// En mode silencieux, on n'affiche pas d'erreur : on laisse simplement le bouton.
if (!silencieux) 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>`;
trajetBtn.textContent = "🔄 Recalculer le trajet";
} catch {
if (!silencieux) resu.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
} finally {
trajetBtn.disabled = false;
}
}
trajetBtn.addEventListener("click", () => calculerTrajet(false));
secTrajet.append(trajetBtn, resu);
// Trajet déjà calculé (après le scrape) : on l'affiche tout de suite (recalcul local instantané).
if ((p["Trajet distance km"] || "").trim()) calculerTrajet(true);
corps.append(secInfos, secContact, secTrajet);
}
$("recherche").addEventListener("input", afficherListe);
$("filtre-statut").addEventListener("change", afficherListe);
// --- Journal de scraping ---
const COULEURS_LOG = { ok: "#16a34a", ajoute: "#16a34a", vide: "#d97706", doublon: "#6b7280" };
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();
}
// --- Disposition (colonne / modale) ---
function definirDisposition(val) {
disposition = (val === "modale") ? "modale" : "colonne";
localStorage.setItem("disposition", disposition);
document.body.dataset.disposition = disposition;
$("cfg-disposition").value = disposition;
fermerModale();
viderDetail();
}
$("cfg-disposition").addEventListener("change", () => definirDisposition($("cfg-disposition").value));
$("modale-fermer").addEventListener("click", fermerModale);
$("modale-detail").addEventListener("click", (e) => { if (e.target === $("modale-detail")) fermerModale(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("modale-detail").hidden) fermerModale(); });
document.body.dataset.disposition = disposition;
$("cfg-disposition").value = disposition;
remplirFiltreStatut();
chargerConfig();
chargerListe();
</script>
</body>
</html>