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:
@@ -5,70 +5,232 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AutoMood — Prospection</title>
|
||||
<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; }
|
||||
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); }
|
||||
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: 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.secondaire { background: #eee; color: #222; }
|
||||
button.secondaire { background: var(--accent-doux); color: var(--accent); }
|
||||
button.secondaire:hover { background: #e3e0f6; }
|
||||
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.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.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); } }
|
||||
|
||||
@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><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>
|
||||
|
||||
<section>
|
||||
<details class="params">
|
||||
<summary>⚙️ Paramètres</summary>
|
||||
<div class="grille" style="margin-top:14px">
|
||||
<!-- ===== 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 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 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">
|
||||
<label>Adresse de départ (pour le calcul des trajets)</label>
|
||||
<input id="cfg-adresse" placeholder="12 rue de la Paix, 44000 Nantes">
|
||||
@@ -91,7 +253,11 @@
|
||||
</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">
|
||||
</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>
|
||||
@@ -101,92 +267,59 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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="journal-rafraichir">Rafraîchir</button>
|
||||
<button type="button" class="danger" id="journal-vider">Vider le journal</button>
|
||||
<button type="button" class="secondaire" id="ia-connexion">🔗 Se connecter à ChatGPT</button>
|
||||
</div>
|
||||
<div id="journal" style="margin-top:12px"></div>
|
||||
</details>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<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. 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 = {
|
||||
"À contacter": "#7f8c8d",
|
||||
"Contacté": "#2980b9",
|
||||
"En discussion": "#f39c12",
|
||||
"Concert programmé": "#27ae60",
|
||||
"Sans réponse": "#95a5a6",
|
||||
"À contacter": "#6b7280",
|
||||
"Contacté": "#2563eb",
|
||||
"En discussion": "#d97706",
|
||||
"Concert programmé": "#16a34a",
|
||||
"Sans réponse": "#64748b",
|
||||
"Refusé": "#c0392b",
|
||||
};
|
||||
const STATUT_DEFAUT = "À contacter";
|
||||
@@ -208,6 +341,15 @@ function statutDe(p) {
|
||||
|
||||
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 = {};
|
||||
@@ -329,15 +471,33 @@ function champControle(col, valeur) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function construireGrilleChamps(grille, valeurs) {
|
||||
grille.innerHTML = "";
|
||||
for (const col of COLONNES) {
|
||||
// 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" + (col === "Notes" ? " large" : "");
|
||||
div.className = "champ" + (CHAMPS_LARGES.includes(col) ? " large" : "");
|
||||
const label = document.createElement("label");
|
||||
label.textContent = col;
|
||||
div.append(label, champControle(col, valeurs[col]));
|
||||
grille.append(div);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,17 +511,17 @@ $("connexion").addEventListener("click", async () => {
|
||||
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("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(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("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-connexion");
|
||||
} finally {
|
||||
bouton.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);
|
||||
}
|
||||
|
||||
// --- Liste des prospects en fiches ---
|
||||
// --- 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");
|
||||
@@ -492,6 +653,9 @@ 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() {
|
||||
@@ -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>`;
|
||||
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 ---
|
||||
@@ -549,105 +737,107 @@ function afficherRelances() {
|
||||
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) {
|
||||
$("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" });
|
||||
montrerVue("prospects");
|
||||
selectionner(index);
|
||||
}
|
||||
|
||||
function carte(p) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "carte";
|
||||
div.dataset.index = p.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 (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 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>` : "";
|
||||
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">
|
||||
<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>` : ""}
|
||||
</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";
|
||||
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", (e) => { e.stopPropagation(); window.open(p["Lien Facebook"], "_blank"); });
|
||||
boutons.append(fb);
|
||||
fb.addEventListener("click", () => window.open(p["Lien Facebook"], "_blank"));
|
||||
actions.append(fb);
|
||||
}
|
||||
const suppr = document.createElement("button");
|
||||
suppr.className = "danger";
|
||||
suppr.textContent = "✕";
|
||||
suppr.title = "Supprimer";
|
||||
suppr.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
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();
|
||||
});
|
||||
boutons.append(suppr);
|
||||
tete.append(boutons);
|
||||
actions.append(suppr);
|
||||
tete.append(actions);
|
||||
pane.append(tete);
|
||||
|
||||
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;
|
||||
construireDetail(pane, p);
|
||||
}
|
||||
|
||||
function construireDetail(detail, p) {
|
||||
const grille = document.createElement("div");
|
||||
grille.className = "grille";
|
||||
construireGrilleChamps(grille, p);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
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";
|
||||
enregistrer.textContent = "Enregistrer les modifications";
|
||||
enregistrer.addEventListener("click", async () => {
|
||||
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}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(donnees),
|
||||
});
|
||||
enregistrer.disabled = false;
|
||||
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");
|
||||
zoneIA.className = "ia-zone";
|
||||
zoneIA.rows = 6;
|
||||
@@ -705,9 +895,14 @@ function construireDetail(detail, p) {
|
||||
peaufinerBtn.textContent = "✨ Peaufiner (IA)";
|
||||
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");
|
||||
trajetBtn.className = "secondaire";
|
||||
trajetBtn.textContent = "🚗 Calculer le trajet";
|
||||
@@ -736,9 +931,9 @@ function construireDetail(detail, p) {
|
||||
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);
|
||||
@@ -746,7 +941,7 @@ $("filtre-statut").addEventListener("change", afficherListe);
|
||||
|
||||
// --- 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() {
|
||||
let entrees = [];
|
||||
|
||||
Reference in New Issue
Block a user