Refonte UI : navigation par onglets et vue prospects en deux colonnes

- Onglets Prospects / Ajouter / Réglages au lieu d'une page unique
- Vue prospects en master-detail (liste à gauche, détail collant à droite)
- Détail découpé en sections Infos / Contact / Trajet, champs regroupés
- Connexions Facebook et ChatGPT regroupées dans Réglages
- Système de design unifié (espacements, typo, gris, contrastes, focus)
This commit is contained in:
jerem
2026-06-13 17:07:09 +02:00
parent 328bf29d59
commit 0952c0bfb5

View File

@@ -5,70 +5,232 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>AutoMood — Prospection</title> <title>AutoMood — Prospection</title>
<style> <style>
:root { --accent: #5b4dbf; --fond: #f5f4fa; --bordure: #ddd; } :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; } * { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 0; background: var(--fond); color: #222; } body {
header { background: var(--accent); color: #fff; padding: 14px 24px; } font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
header h1 { margin: 0; font-size: 20px; } margin: 0; background: var(--fond); color: var(--texte);
main { max-width: 1200px; margin: 24px auto; padding: 0 16px; } font-size: var(--t-sm); line-height: 1.5; -webkit-font-smoothing: antialiased;
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; } /* En-tête + onglets */
.ligne-url input { flex: 1; } header { background: var(--surface); border-bottom: 1px solid var(--bordure); position: sticky; top: 0; z-index: 20; }
input, button, select, textarea { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); } .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; } textarea { width: 100%; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } 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 { 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:disabled { opacity: .5; cursor: wait; }
button.secondaire { background: #eee; color: #222; } button.secondaire { background: var(--accent-doux); color: var(--accent); }
button.secondaire:hover { background: #e3e0f6; }
button.danger { background: #c0392b; } button.danger { background: #c0392b; }
.message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; } 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.erreur { display: block; background: #fdecea; color: #b03a2e; }
.message.info { display: block; background: #eaf2fd; color: #1a5276; } .message.info { display: block; background: var(--accent-doux); color: var(--accent-fonce); }
#formulaire { display: none; margin-top: 16px; }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } #formulaire { display: none; margin-top: var(--e4); }
.champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } .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%; } .champ input, .champ select, .champ textarea { width: 100%; }
.grille .champ.large { grid-column: 1 / -1; } .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; } .actions { margin-top: var(--e4); display: flex; gap: var(--e2); flex-wrap: wrap; }
.chip-relance { background: #e67e22; color: #fff; border: none; border-radius: 99px; padding: 4px 11px; font-size: 12px; cursor: pointer; } .indice { color: var(--texte-faible); font-size: var(--t-xs); margin: var(--e1) 0 var(--e3); }
.badge-relance { background: #e67e22; color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; } code { background: var(--fond); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
.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; } /* Bannière relances */
.carte-tete { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; } .banniere-relance { background: #fff4e2; border: 1px solid #f0c987; color: #8a5a00; border-radius: var(--r-m); padding: var(--e3) var(--e4); margin-bottom: var(--e4); }
.carte-tete:hover { background: #faf9fd; } .relance-chips { display: flex; flex-wrap: wrap; gap: var(--e2); margin-top: var(--e2); }
.carte-titre { display: flex; align-items: center; gap: 8px; min-width: 260px; flex: 1; } .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; }
.carte-titre strong { font-size: 14px; } .chip-relance:hover { background: #cf6a16; }
.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; } /* Liste */
.carte-infos { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13px; align-items: center; } .barre-liste { display: flex; justify-content: space-between; align-items: center; gap: var(--e3); flex-wrap: wrap; margin-bottom: var(--e3); }
.sous { color: #666; font-size: 12px; } .barre-liste h2 { margin: 0; }
.carte-boutons { display: flex; gap: 6px; margin-left: auto; } .filtres { display: flex; gap: var(--e2); margin-bottom: var(--e3); flex-wrap: wrap; }
.carte-boutons button { padding: 4px 9px; font-size: 12px; } .filtres input { flex: 1; min-width: 160px; }
.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; } /* Deux colonnes : liste + détail */
.trajet-resu { margin-top: 8px; font-size: 14px; } .deux-colonnes { display: grid; grid-template-columns: 320px 1fr; gap: var(--e4); align-items: start; }
.vide { color: #999; font-style: italic; } .colonne-detail { position: sticky; top: 104px; max-height: calc(100vh - 128px); overflow-y: auto; }
.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; } /* Item de liste (compact, sélectionnable) */
.filtres input { flex: 1; } .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; }
details.params summary { cursor: pointer; font-weight: 600; font-size: 16px; } .item:hover { border-color: var(--accent); }
.indice { color: #888; font-size: 12px; margin: 2px 0 10px; } .item.selectionnee { border-color: var(--accent); background: var(--accent-doux); box-shadow: inset 3px 0 0 var(--accent); }
.ligne-progres { padding: 4px 0; font-size: 13px; border-bottom: 1px solid #f0eef8; } .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 { 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; } .spinner.sombre { border-color: var(--accent); border-top-color: transparent; }
@keyframes tourne { to { transform: rotate(360deg); } } @keyframes tourne { to { transform: rotate(360deg); } }
@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> </style>
</head> </head>
<body> <body>
<header><h1>🎸 AutoMood — Prospection de lieux de concert</h1></header> <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> <main>
<section> <!-- ===== VUE PROSPECTS ===== -->
<details class="params"> <div class="vue" id="vue-prospects">
<summary>⚙️ Paramètres</summary> <div id="relances" class="banniere-relance" style="display:none"></div>
<div class="grille" style="margin-top:14px"> <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>Trajet & relance</h2>
<div class="grille">
<div class="champ"> <div class="champ">
<label>Adresse de départ (pour le calcul des trajets)</label> <label>Adresse de départ (pour le calcul des trajets)</label>
<input id="cfg-adresse" placeholder="12 rue de la Paix, 44000 Nantes"> <input id="cfg-adresse" placeholder="12 rue de la Paix, 44000 Nantes">
@@ -91,7 +253,11 @@
</div> </div>
</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> <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"> </section>
<section class="bloc">
<h2>Message & IA</h2>
<div class="champ">
<label>Modèle de message de prise de contact</label> <label>Modèle de message de prise de contact</label>
<textarea id="cfg-message" rows="6"></textarea> <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> <p class="indice">Variables disponibles : <code>{nom}</code> (nom du lieu), <code>{ville}</code> (devient « à Ville », ou rien), <code>{type}</code>.</p>
@@ -101,92 +267,59 @@
<input id="cfg-ia-modele" type="text" placeholder="chatgpt/gpt-5.4"> <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> <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>
<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"> <div class="actions">
<button id="cfg-enregistrer">Enregistrer les paramètres</button> <button id="cfg-enregistrer">Enregistrer les paramètres</button>
</div> </div>
<div id="message-config" class="message"></div> <div id="message-config" class="message"></div>
</details>
</section> </section>
<section> <section class="bloc">
<h2>Nouveau prospect</h2> <h2>Connexions</h2>
<div class="ligne-url"> <div class="champ">
<input id="url" type="url" placeholder="https://www.facebook.com/nom-du-lieu" autofocus> <label>ChatGPT (pour la génération par IA)</label>
<button id="analyser">Analyser</button> <div id="ia-statut" class="indice">Vérification…</div>
<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"> <div class="actions" style="margin-top:0">
<button type="button" class="secondaire" id="journal-rafraichir">Rafraîchir</button> <button type="button" class="secondaire" id="ia-connexion">🔗 Se connecter à ChatGPT</button>
<button type="button" class="danger" id="journal-vider">Vider le journal</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> </div>
<div id="journal" style="margin-top:12px"></div>
</details>
</section> </section>
</div>
</main> </main>
<script> <script>
const COLONNES = ["Nom du prospect","Statut","Département","Ville","Code Postal","Adresse","Date d'ajout", const COLONNES = ["Nom du prospect","Statut","Département","Ville","Code Postal","Adresse","Date d'ajout",
"Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook","Notes"]; "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. // Statuts encore « actifs » pour lesquels une relance a du sens.
const STATUTS_A_RELANCER = ["Contacté", "En discussion"]; const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant. // Statut de prospection -> couleur du badge (contrastée pour texte blanc). L'ordre sert au menu déroulant.
const STATUTS = { const STATUTS = {
"À contacter": "#7f8c8d", "À contacter": "#6b7280",
"Contacté": "#2980b9", "Contacté": "#2563eb",
"En discussion": "#f39c12", "En discussion": "#d97706",
"Concert programmé": "#27ae60", "Concert programmé": "#16a34a",
"Sans réponse": "#95a5a6", "Sans réponse": "#64748b",
"Refusé": "#c0392b", "Refusé": "#c0392b",
}; };
const STATUT_DEFAUT = "À contacter"; const STATUT_DEFAUT = "À contacter";
@@ -208,6 +341,15 @@ function statutDe(p) {
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); 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 --- // --- Paramètres ---
let config = {}; let config = {};
@@ -329,15 +471,33 @@ function champControle(col, valeur) {
return input; return input;
} }
function construireGrilleChamps(grille, valeurs) { // Construit un champ (label + contrôle) prêt à insérer dans une grille.
grille.innerHTML = ""; function champBloc(col, valeur) {
for (const col of COLONNES) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "champ" + (col === "Notes" ? " large" : ""); div.className = "champ" + (CHAMPS_LARGES.includes(col) ? " large" : "");
const label = document.createElement("label"); const label = document.createElement("label");
label.textContent = col; label.textContent = col;
div.append(label, champControle(col, valeurs[col])); div.append(label, champControle(col, valeur));
grille.append(div); 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);
} }
} }
@@ -351,17 +511,17 @@ $("connexion").addEventListener("click", async () => {
bouton.disabled = true; bouton.disabled = true;
$("analyser").disabled = true; $("analyser").disabled = true;
bouton.innerHTML = '<span class="spinner"></span>En attente de votre connexion…'; 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("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 { try {
const rep = await fetch("/api/login", { method: "POST" }); const rep = await fetch("/api/login", { method: "POST" });
const donnees = await rep.json(); 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(rep.ok ? "Connexion Facebook enregistrée ✔ Vous pouvez analyser des pages." : (donnees.message || "Échec de la connexion."), rep.ok ? "info" : "erreur", "message-connexion");
} catch { } catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur"); message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-connexion");
} finally { } finally {
bouton.disabled = false; bouton.disabled = false;
$("analyser").disabled = false; $("analyser").disabled = false;
bouton.textContent = "🔑 Connexion Facebook"; bouton.textContent = "🔑 Se connecter à Facebook";
} }
}); });
@@ -474,9 +634,10 @@ function afficherProgresLot(res, compte) {
$("progres-lot").append(div); $("progres-lot").append(div);
} }
// --- Liste des prospects en fiches --- // --- Liste des prospects (colonne gauche) ---
let prospects = []; let prospects = [];
let selectionIndex = null; // index du prospect affiché dans le panneau de détail
function remplirFiltreStatut() { function remplirFiltreStatut() {
const sel = $("filtre-statut"); const sel = $("filtre-statut");
@@ -492,6 +653,9 @@ async function chargerListe() {
prospects = await (await fetch("/api/prospects")).json(); prospects = await (await fetch("/api/prospects")).json();
afficherListe(); afficherListe();
afficherRelances(); 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() { function afficherListe() {
@@ -508,7 +672,31 @@ function afficherListe() {
liste.innerHTML = `<p class="vide">${prospects.length ? "Aucun prospect ne correspond au filtre." : "Aucun prospect pour le moment."}</p>`; liste.innerHTML = `<p class="vide">${prospects.length ? "Aucun prospect ne correspond au filtre." : "Aucun prospect pour le moment."}</p>`;
return; return;
} }
for (const p of visibles) liste.append(carte(p)); 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 --- // --- Relances ---
@@ -549,105 +737,107 @@ function afficherRelances() {
b.addEventListener("click", () => ouvrirFiche(+b.dataset.index))); b.addEventListener("click", () => ouvrirFiche(+b.dataset.index)));
} }
// Réinitialise les filtres, déplie la fiche du prospect et la fait défiler à l'écran. // Sélectionne un prospect (depuis un chip de relance, etc.).
function ouvrirFiche(index) { function ouvrirFiche(index) {
$("recherche").value = ""; montrerVue("prospects");
$("filtre-statut").value = ""; selectionner(index);
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) { // --- Panneau de détail (colonne droite) ---
const div = document.createElement("div");
div.className = "carte"; function selectionner(index) {
div.dataset.index = p.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 (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");
}
function afficherDetail(p) {
const pane = $("detail-pane");
pane.innerHTML = "";
const tete = document.createElement("div");
tete.className = "carte-tete";
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
const st = statutDe(p); const st = statutDe(p);
const couleur = STATUTS[st] || "#7f8c8d"; const couleur = STATUTS[st] || "#6b7280";
const relance = joursDeRelance(p); const tete = document.createElement("div");
const contacte = (p["Date de contact"] || "").trim() tete.className = "detail-tete";
? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
tete.innerHTML = ` tete.innerHTML = `
<div class="titre">
<h2>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</h2>
<div class="carte-titre"> <div class="carte-titre">
<strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong> <span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""} ${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""}
</div> </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>`; </div>`;
const boutons = document.createElement("div"); const actions = document.createElement("div");
boutons.className = "carte-boutons"; actions.className = "detail-tete-actions";
if (p["Lien Facebook"]) { if (p["Lien Facebook"]) {
const fb = document.createElement("button"); const fb = document.createElement("button");
fb.className = "secondaire"; fb.className = "secondaire";
fb.textContent = "Facebook ↗"; fb.textContent = "Facebook ↗";
fb.addEventListener("click", (e) => { e.stopPropagation(); window.open(p["Lien Facebook"], "_blank"); }); fb.addEventListener("click", () => window.open(p["Lien Facebook"], "_blank"));
boutons.append(fb); actions.append(fb);
} }
const suppr = document.createElement("button"); const suppr = document.createElement("button");
suppr.className = "danger"; suppr.className = "danger";
suppr.textContent = ""; suppr.textContent = "Supprimer";
suppr.title = "Supprimer"; suppr.addEventListener("click", async () => {
suppr.addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Supprimer « ${p["Nom du prospect"] || "cette ligne"} » ?`)) return; if (!confirm(`Supprimer « ${p["Nom du prospect"] || "cette ligne"} » ?`)) return;
await fetch(`/api/prospects/${p.index}`, { method: "DELETE" }); await fetch(`/api/prospects/${p.index}`, { method: "DELETE" });
selectionIndex = null;
chargerListe(); chargerListe();
}); });
boutons.append(suppr); actions.append(suppr);
tete.append(boutons); tete.append(actions);
pane.append(tete);
const detail = document.createElement("div"); construireDetail(pane, p);
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) { function sectionDetail(titre) {
const grille = document.createElement("div"); const s = document.createElement("div");
grille.className = "grille"; s.className = "detail-section";
construireGrilleChamps(grille, p); const h = document.createElement("h3");
h.textContent = titre;
const actions = document.createElement("div"); s.append(h);
actions.className = "actions"; 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"); const enregistrer = document.createElement("button");
enregistrer.textContent = "Enregistrer"; enregistrer.textContent = "Enregistrer les modifications";
enregistrer.addEventListener("click", async () => { enregistrer.addEventListener("click", async () => {
const donnees = {}; const donnees = {};
for (const ctrl of grille.querySelectorAll("input, select, textarea")) donnees[ctrl.name] = ctrl.value; 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}`, { const rep = await fetch(`/api/prospects/${p.index}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(donnees), body: JSON.stringify(donnees),
}); });
enregistrer.disabled = false;
if (rep.ok) chargerListe(); if (rep.ok) chargerListe();
}); });
actions.append(enregistrer); actionsInfos.append(enregistrer);
secInfos.append(champsWrap, actionsInfos);
// Prise de contact — zone éditable partagée (modèle statique ou message généré par IA) // --- Prise de contact ---
const secContact = sectionDetail("✉️ Prise de contact");
const zoneIA = document.createElement("textarea"); const zoneIA = document.createElement("textarea");
zoneIA.className = "ia-zone"; zoneIA.className = "ia-zone";
zoneIA.rows = 6; zoneIA.rows = 6;
@@ -705,9 +895,14 @@ function construireDetail(detail, p) {
peaufinerBtn.textContent = "✨ Peaufiner (IA)"; peaufinerBtn.textContent = "✨ Peaufiner (IA)";
peaufinerBtn.addEventListener("click", () => genererIA("peaufiner", peaufinerBtn)); peaufinerBtn.addEventListener("click", () => genererIA("peaufiner", peaufinerBtn));
actions.append(copier, genererBtn, 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 // --- Trajet & carburant ---
const secTrajet = sectionDetail("🚗 Trajet & carburant");
const trajetBtn = document.createElement("button"); const trajetBtn = document.createElement("button");
trajetBtn.className = "secondaire"; trajetBtn.className = "secondaire";
trajetBtn.textContent = "🚗 Calculer le trajet"; trajetBtn.textContent = "🚗 Calculer le trajet";
@@ -736,9 +931,9 @@ function construireDetail(detail, p) {
trajetBtn.disabled = false; trajetBtn.disabled = false;
} }
}); });
actions.append(trajetBtn); secTrajet.append(trajetBtn, resu);
detail.append(grille, actions, iaInfo, zoneIA, resu); corps.append(secInfos, secContact, secTrajet);
} }
$("recherche").addEventListener("input", afficherListe); $("recherche").addEventListener("input", afficherListe);
@@ -746,7 +941,7 @@ $("filtre-statut").addEventListener("change", afficherListe);
// --- Journal de scraping --- // --- Journal de scraping ---
const COULEURS_LOG = { ok: "#27ae60", ajoute: "#27ae60", vide: "#f39c12", doublon: "#7f8c8d" }; const COULEURS_LOG = { ok: "#16a34a", ajoute: "#16a34a", vide: "#d97706", doublon: "#6b7280" };
async function chargerLogs() { async function chargerLogs() {
let entrees = []; let entrees = [];