Robustesse (backup, export Excel, journal) + notes libres et relances

- Sauvegarde automatique du CSV avant chaque écriture (backups/, 30 versions)
- Export Excel .xlsx sans dépendance (module excel.py)
- Journal de scraping (scrape.log) + panneau et endpoints /api/logs
- Note libre par prospect (colonne Notes, zone de texte)
- Notification de relance in-app (statut Contacté/En discussion + délai configurable)
This commit is contained in:
jerem
2026-06-13 15:47:49 +02:00
parent 1cf427a0f2
commit 02180f1c7b
5 changed files with 317 additions and 9 deletions

2
.gitignore vendored
View File

@@ -2,4 +2,6 @@
fb_profile/ fb_profile/
prospects.csv prospects.csv
config.json config.json
backups/
scrape.log
__pycache__/ __pycache__/

View File

@@ -31,11 +31,21 @@ Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s
- **Statut de prospection** : chaque prospect a un statut (À contacter, Contacté, En discussion, Concert programmé, Sans réponse, Refusé) affiché en pastille colorée. Un menu déroulant au-dessus de la liste permet de filtrer par statut. - **Statut de prospection** : chaque prospect a un statut (À contacter, Contacté, En discussion, Concert programmé, Sans réponse, Refusé) affiché en pastille colorée. Un menu déroulant au-dessus de la liste permet de filtrer par statut.
- **Import en masse** : dans la section *Import en masse*, collez plusieurs liens Facebook (un par ligne) et cliquez sur **Importer la liste**. Les lieux sont analysés un par un et ajoutés automatiquement ; l'avancement s'affiche en direct et les doublons (déjà présents) sont ignorés. - **Import en masse** : dans la section *Import en masse*, collez plusieurs liens Facebook (un par ligne) et cliquez sur **Importer la liste**. Les lieux sont analysés un par un et ajoutés automatiquement ; l'avancement s'affiche en direct et les doublons (déjà présents) sont ignorés.
- **Prise de contact** : en dépliant une fiche, des boutons permettent d'**écrire un mail** (sujet et corps pré-remplis depuis le modèle de message), d'**appeler**, d'ouvrir la **page Facebook/Messenger** ou de **copier le message**. Le modèle se règle dans **⚙️ Paramètres** (variables `{nom}`, `{ville}`, `{type}`). - **Prise de contact** : en dépliant une fiche, un bouton permet de **copier le message** de prise de contact. Le modèle se règle dans **⚙️ Paramètres** (variables `{nom}`, `{ville}`, `{type}`).
- **Notes libres** : chaque prospect a un champ **Notes** (visible en dépliant la fiche) pour consigner compte-rendu d'appel, contraintes, disponibilités… Une fiche avec des notes affiche un repère 📝.
- **Relances** : les prospects au statut *Contacté* ou *En discussion* dont la **date de contact** dépasse le **délai de relance** (réglable dans les Paramètres, 7 jours par défaut) sont signalés par une notification 🔔 en haut de la liste et un badge sur la fiche. Cliquer sur un nom dans la notification ouvre directement la fiche. Aucun mail n'est envoyé : c'est un simple rappel visuel.
- **Distance, carburant et péage** : renseignez votre **adresse de départ**, la **consommation** (L/100 km), le **prix du carburant** et le **tarif péage** (€/km d'autoroute) dans les Paramètres, puis cliquez sur **🚗 Calculer le trajet** dans une fiche. L'application estime la distance routière, la durée et le coût total — **carburant + péage** — pour l'aller et l'aller-retour, via OpenStreetMap (Nominatim + OSRM, gratuit, sans clé). Le péage est estimé à partir des kilomètres d'autoroute du trajet × le tarif configuré (≈ 0,10 €/km ; mettez 0 pour l'ignorer) : c'est une approximation, certaines autoroutes « A » étant gratuites. Une connexion Internet est requise pour ce calcul. - **Distance, carburant et péage** : renseignez votre **adresse de départ**, la **consommation** (L/100 km), le **prix du carburant** et le **tarif péage** (€/km d'autoroute) dans les Paramètres, puis cliquez sur **🚗 Calculer le trajet** dans une fiche. L'application estime la distance routière, la durée et le coût total — **carburant + péage** — pour l'aller et l'aller-retour, via OpenStreetMap (Nominatim + OSRM, gratuit, sans clé). Le péage est estimé à partir des kilomètres d'autoroute du trajet × le tarif configuré (≈ 0,10 €/km ; mettez 0 pour l'ignorer) : c'est une approximation, certaines autoroutes « A » étant gratuites. Une connexion Internet est requise pour ce calcul.
Les paramètres sont stockés dans `config.json` (non versionné, car il contient votre adresse personnelle). Les paramètres sont stockés dans `config.json` (non versionné, car il contient votre adresse personnelle).
## Robustesse
- **Sauvegardes automatiques** : avant chaque écriture, l'ancien `prospects.csv` est copié dans le dossier `backups/` (les 30 versions les plus récentes sont conservées). En cas de fausse manip, vous pouvez restaurer une version en recopiant le fichier voulu à la place de `prospects.csv` (app arrêtée).
- **Export Excel** : le bouton **Excel (.xlsx)** télécharge un vrai classeur (en plus du **CSV**), prêt à ouvrir dans Excel/Numbers/LibreOffice.
- **Journal de scraping** : le panneau **🩺 Journal de scraping** (en bas) liste les analyses passées avec leur résultat (`ok`, `vide`, `doublon`, ou code d'erreur comme `login_required`, `redirection`…), pour comprendre rapidement pourquoi une page n'a rien donné. Il est stocké dans `scrape.log` (non versionné).
Les dossiers/fichiers `backups/` et `scrape.log` restent locaux (non versionnés).
## À savoir ## À savoir
- ⚠️ N'éditez pas `prospects.csv` dans Excel pendant que l'app tourne : toute modification via l'interface réécrit le fichier et écraserait vos changements Excel. Ouvrez-le en lecture seulement, ou éditez via l'interface. - ⚠️ N'éditez pas `prospects.csv` dans Excel pendant que l'app tourne : toute modification via l'interface réécrit le fichier et écraserait vos changements Excel. Ouvrez-le en lecture seulement, ou éditez via l'interface.

92
app.py
View File

@@ -5,11 +5,12 @@ import json
import os import os
import threading import threading
import webbrowser import webbrowser
from datetime import date from datetime import date, datetime
from pathlib import Path from pathlib import Path
from flask import Flask, Response, jsonify, request, send_file, stream_with_context from flask import Flask, Response, jsonify, request, send_file, stream_with_context
import excel
import extractor import extractor
import scraper import scraper
import trajet import trajet
@@ -17,10 +18,13 @@ import trajet
DOSSIER = Path(__file__).parent DOSSIER = Path(__file__).parent
CSV_PATH = DOSSIER / "prospects.csv" CSV_PATH = DOSSIER / "prospects.csv"
CONFIG_PATH = DOSSIER / "config.json" CONFIG_PATH = DOSSIER / "config.json"
BACKUP_DIR = DOSSIER / "backups"
JOURNAL_PATH = DOSSIER / "scrape.log"
MAX_BACKUPS = 30
COLONNES = [ COLONNES = [
"Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse", "Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse",
"Date d'ajout", "Date de contact", "Nom de contact", "Date d'ajout", "Date de contact", "Nom de contact",
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes",
] ]
STATUT_DEFAUT = "À contacter" STATUT_DEFAUT = "À contacter"
@@ -29,6 +33,7 @@ CONFIG_DEFAUT = {
"conso_l_100km": 6.5, "conso_l_100km": 6.5,
"prix_carburant": 1.90, "prix_carburant": 1.90,
"cout_peage_km": 0.10, "cout_peage_km": 0.10,
"delai_relance_jours": 7,
"modele_message": ( "modele_message": (
"Bonjour,\n\n" "Bonjour,\n\n"
"Je me permets de vous contacter au sujet de « {nom} »{ville}. " "Je me permets de vous contacter au sujet de « {nom} »{ville}. "
@@ -53,7 +58,30 @@ def lire_prospects():
return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur] return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur]
def _sauvegarder_csv():
"""Copie le CSV actuel dans backups/ avant qu'il ne soit écrasé.
Ne garde que les MAX_BACKUPS plus récents et saute la copie si rien n'a
changé depuis la dernière sauvegarde (évite les doublons inutiles).
"""
if not CSV_PATH.exists():
return
try:
contenu = CSV_PATH.read_bytes()
BACKUP_DIR.mkdir(exist_ok=True)
sauvegardes = sorted(BACKUP_DIR.glob("prospects-*.csv"))
if sauvegardes and sauvegardes[-1].read_bytes() == contenu:
return # identique à la dernière sauvegarde
horodatage = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
(BACKUP_DIR / f"prospects-{horodatage}.csv").write_bytes(contenu)
for vieux in sauvegardes[:-(MAX_BACKUPS - 1)]:
vieux.unlink(missing_ok=True)
except Exception:
pass # une sauvegarde ratée ne doit jamais bloquer l'écriture
def ecrire_prospects(lignes): def ecrire_prospects(lignes):
_sauvegarder_csv()
tmp = CSV_PATH.with_suffix(".csv.tmp") tmp = CSV_PATH.with_suffix(".csv.tmp")
with open(tmp, "w", encoding="utf-8-sig", newline="") as f: with open(tmp, "w", encoding="utf-8-sig", newline="") as f:
ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore") ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore")
@@ -62,6 +90,17 @@ def ecrire_prospects(lignes):
os.replace(tmp, CSV_PATH) os.replace(tmp, CSV_PATH)
def journaliser(url, statut, message=""):
"""Ajoute une ligne au journal de scraping (date, statut, url, message)."""
horodatage = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
ligne = "\t".join((horodatage, statut, url or "", message or "")).rstrip() + "\n"
try:
with open(JOURNAL_PATH, "a", encoding="utf-8") as f:
f.write(ligne)
except Exception:
pass
def lire_config(): def lire_config():
config = dict(CONFIG_DEFAUT) config = dict(CONFIG_DEFAUT)
if CONFIG_PATH.exists(): if CONFIG_PATH.exists():
@@ -112,11 +151,16 @@ def api_scrape():
try: try:
resultat = scraper.scrape(url) resultat = scraper.scrape(url)
champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"]) champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"])
trouves = [c for c in ("Nom du prospect", "Téléphone", "Email", "Ville", "Adresse") if champs.get(c)]
journaliser(url, "ok" if champs.get("Nom du prospect") else "vide",
("trouvé : " + ", ".join(trouves)) if trouves else "aucun champ extrait")
return jsonify(champs) return jsonify(champs)
except scraper.ErreurScrape as e: except scraper.ErreurScrape as e:
journaliser(url, e.code, str(e))
statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422} statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422}
return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500) return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500)
except Exception as e: except Exception as e:
journaliser(url, "erreur", str(e))
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500 return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
finally: finally:
verrou_scrape.release() verrou_scrape.release()
@@ -174,6 +218,42 @@ def api_export():
return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv") return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv")
@app.get("/api/export.xlsx")
def api_export_xlsx():
contenu = excel.construire_xlsx(COLONNES, lire_prospects())
return Response(
contenu,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=prospects.xlsx"},
)
@app.get("/api/logs")
def api_logs():
if not JOURNAL_PATH.exists():
return jsonify([])
with open(JOURNAL_PATH, encoding="utf-8") as f:
lignes = f.readlines()[-100:]
entrees = []
for ligne in reversed(lignes): # le plus récent en premier
parts = ligne.rstrip("\n").split("\t")
if len(parts) >= 3:
entrees.append({
"date": parts[0], "statut": parts[1], "url": parts[2],
"message": parts[3] if len(parts) > 3 else "",
})
return jsonify(entrees)
@app.delete("/api/logs")
def api_logs_vider():
try:
JOURNAL_PATH.unlink(missing_ok=True)
except Exception:
pass
return jsonify({"ok": True})
def _ajouter_resultat_lot(resultat): def _ajouter_resultat_lot(resultat):
"""Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage). """Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage).
@@ -181,6 +261,7 @@ def _ajouter_resultat_lot(resultat):
""" """
url = resultat["url"] url = resultat["url"]
if not resultat["ok"]: if not resultat["ok"]:
journaliser(url, resultat.get("code", "erreur"), resultat.get("message", "Échec."))
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")} return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
champs = extractor.extraire( champs = extractor.extraire(
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"]) resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
@@ -188,10 +269,12 @@ def _ajouter_resultat_lot(resultat):
lignes = lire_prospects() lignes = lire_prospects()
lien = (champs.get("Lien Facebook") or "").strip() lien = (champs.get("Lien Facebook") or "").strip()
if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes): if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes):
journaliser(url, "doublon", champs["Nom du prospect"])
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]} return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
champs["Statut"] = STATUT_DEFAUT champs["Statut"] = STATUT_DEFAUT
lignes.append({col: champs.get(col, "") for col in COLONNES}) lignes.append({col: champs.get(col, "") for col in COLONNES})
ecrire_prospects(lignes) ecrire_prospects(lignes)
journaliser(url, "ajoute", champs["Nom du prospect"])
return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]} return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]}
@@ -235,6 +318,11 @@ def api_config_ecrire():
config[cle] = float(str(donnees[cle]).replace(",", ".")) config[cle] = float(str(donnees[cle]).replace(",", "."))
except (TypeError, ValueError): except (TypeError, ValueError):
pass # valeur non numérique : on garde l'ancienne pass # valeur non numérique : on garde l'ancienne
if "delai_relance_jours" in donnees:
try:
config["delai_relance_jours"] = max(0, int(float(str(donnees["delai_relance_jours"]).replace(",", "."))))
except (TypeError, ValueError):
pass
ecrire_config(config) ecrire_config(config)
return jsonify({"ok": True, "config": config}) return jsonify({"ok": True, "config": config})

78
excel.py Normal file
View File

@@ -0,0 +1,78 @@
"""Génération d'un classeur .xlsx minimal, sans dépendance.
Un fichier .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
« inline » (t="inlineStr") pour éviter la table des chaînes partagées : le résultat
s'ouvre dans Excel, Numbers et LibreOffice. Bibliothèque standard uniquement.
"""
import io
import zipfile
from xml.sax.saxutils import escape
def _ref(col, ligne):
"""Référence de cellule façon tableur : (0, 1) -> « A1 », (27, 3) -> « AB3 »."""
lettres, n = "", col
while True:
n, reste = divmod(n, 26)
lettres = chr(65 + reste) + lettres
if n == 0:
break
n -= 1
return f"{lettres}{ligne}"
def _cellule(col, ligne, valeur):
texte = escape("" if valeur is None else str(valeur))
return (f'<c r="{_ref(col, ligne)}" t="inlineStr">'
f'<is><t xml:space="preserve">{texte}</t></is></c>')
def construire_xlsx(colonnes, lignes, nom_feuille="Prospects"):
"""Octets d'un classeur .xlsx : une feuille avec en-tête puis une ligne par dict."""
rangs = ['<row r="1">' + "".join(_cellule(c, 1, colonnes[c]) for c in range(len(colonnes))) + "</row>"]
for i, ligne in enumerate(lignes, start=2):
cellules = "".join(_cellule(c, i, ligne.get(colonnes[c], "")) for c in range(len(colonnes)))
rangs.append(f'<row r="{i}">{cellules}</row>')
feuille = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(rangs)}</sheetData></worksheet>'
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'</Types>'
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'</Relationships>'
)
workbook = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
f'<sheets><sheet name="{escape(nom_feuille[:31])}" sheetId="1" r:id="rId1"/></sheets></workbook>'
)
workbook_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
'</Relationships>'
)
tampon = io.BytesIO()
with zipfile.ZipFile(tampon, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", content_types)
z.writestr("_rels/.rels", rels)
z.writestr("xl/workbook.xml", workbook)
z.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
z.writestr("xl/worksheets/sheet1.xml", feuille)
return tampon.getvalue()

View File

@@ -28,7 +28,12 @@
#formulaire { display: none; margin-top: 16px; } #formulaire { display: none; margin-top: 16px; }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } .champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
.champ input, .champ select { width: 100%; } .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; } .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 { 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 { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; }
@@ -80,6 +85,10 @@
<label>Tarif péage (€ / km d'autoroute)</label> <label>Tarif péage (€ / km d'autoroute)</label>
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10"> <input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
</div> </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> </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"> <div class="champ" style="margin-top:12px">
@@ -123,9 +132,13 @@
</section> </section>
<section> <section>
<div id="relances" class="banniere-relance" style="display:none"></div>
<div class="barre-liste"> <div class="barre-liste">
<h2 style="margin:0">Liste des prospects <span id="compteur"></span></h2> <h2 style="margin:0">Liste des prospects <span id="compteur"></span></h2>
<a href="/api/export"><button type="button" class="secondaire">Télécharger le CSV</button></a> <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>
<div class="filtres"> <div class="filtres">
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…"> <input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
@@ -134,10 +147,25 @@
<div id="liste"></div> <div id="liste"></div>
</section> </section>
<section>
<details id="journal-details">
<summary style="cursor:pointer;font-weight:600;font-size:16px">🩺 Journal de scraping</summary>
<p class="indice">Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).</p>
<div class="actions" style="margin-top:0">
<button type="button" class="secondaire" id="journal-rafraichir">Rafraîchir</button>
<button type="button" class="danger" id="journal-vider">Vider le journal</button>
</div>
<div id="journal" style="margin-top:12px"></div>
</details>
</section>
</main> </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"]; "Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook","Notes"];
// Statuts encore « actifs » pour lesquels une relance a du sens.
const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant. // Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
const STATUTS = { const STATUTS = {
@@ -179,7 +207,9 @@ async function chargerConfig() {
$("cfg-conso").value = config.conso_l_100km ?? ""; $("cfg-conso").value = config.conso_l_100km ?? "";
$("cfg-prix").value = config.prix_carburant ?? ""; $("cfg-prix").value = config.prix_carburant ?? "";
$("cfg-peage").value = config.cout_peage_km ?? ""; $("cfg-peage").value = config.cout_peage_km ?? "";
$("cfg-relance").value = config.delai_relance_jours ?? "";
$("cfg-message").value = config.modele_message || ""; $("cfg-message").value = config.modele_message || "";
afficherRelances();
} }
$("cfg-enregistrer").addEventListener("click", async () => { $("cfg-enregistrer").addEventListener("click", async () => {
@@ -188,6 +218,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
conso_l_100km: $("cfg-conso").value, conso_l_100km: $("cfg-conso").value,
prix_carburant: $("cfg-prix").value, prix_carburant: $("cfg-prix").value,
cout_peage_km: $("cfg-peage").value, cout_peage_km: $("cfg-peage").value,
delai_relance_jours: $("cfg-relance").value,
modele_message: $("cfg-message").value, modele_message: $("cfg-message").value,
}; };
const rep = await fetch("/api/config", { const rep = await fetch("/api/config", {
@@ -198,6 +229,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
if (rep.ok) { if (rep.ok) {
config = (await rep.json()).config; config = (await rep.json()).config;
message("Paramètres enregistrés ✔", "info", "message-config"); message("Paramètres enregistrés ✔", "info", "message-config");
afficherRelances();
} else { } else {
message("Échec de l'enregistrement.", "erreur", "message-config"); message("Échec de l'enregistrement.", "erreur", "message-config");
} }
@@ -228,6 +260,12 @@ function champControle(col, valeur) {
} }
return sel; 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"); const input = document.createElement("input");
input.name = col; input.name = col;
input.value = valeur || ""; input.value = valeur || "";
@@ -239,7 +277,7 @@ function construireGrilleChamps(grille, valeurs) {
grille.innerHTML = ""; grille.innerHTML = "";
for (const col of COLONNES) { for (const col of COLONNES) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "champ"; div.className = "champ" + (col === "Notes" ? " 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, valeurs[col]));
@@ -293,13 +331,14 @@ $("analyser").addEventListener("click", async () => {
} finally { } finally {
bouton.disabled = false; bouton.disabled = false;
bouton.textContent = "Analyser"; bouton.textContent = "Analyser";
rafraichirLogsSiOuvert();
} }
}); });
$("formulaire").addEventListener("submit", async (e) => { $("formulaire").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const ligne = {}; const ligne = {};
for (const ctrl of $("grille-champs").querySelectorAll("input, select")) ligne[ctrl.name] = ctrl.value; for (const ctrl of $("grille-champs").querySelectorAll("input, select, textarea")) ligne[ctrl.name] = ctrl.value;
const rep = await fetch("/api/prospects", { const rep = await fetch("/api/prospects", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -358,6 +397,7 @@ $("importer").addEventListener("click", async () => {
} }
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot"); message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
chargerListe(); chargerListe();
rafraichirLogsSiOuvert();
} catch { } catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot"); message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
} finally { } finally {
@@ -395,6 +435,7 @@ function remplirFiltreStatut() {
async function chargerListe() { async function chargerListe() {
prospects = await (await fetch("/api/prospects")).json(); prospects = await (await fetch("/api/prospects")).json();
afficherListe(); afficherListe();
afficherRelances();
} }
function afficherListe() { function afficherListe() {
@@ -414,15 +455,67 @@ function afficherListe() {
for (const p of visibles) liste.append(carte(p)); for (const p of visibles) liste.append(carte(p));
} }
// --- Relances ---
function parseDateFr(s) {
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec((s || "").trim());
return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
}
// Nombre de jours depuis le contact si le prospect est à relancer, sinon null.
function joursDeRelance(p) {
if (!STATUTS_A_RELANCER.includes(statutDe(p))) return null;
const d = parseDateFr(p["Date de contact"]);
if (!d) return null;
const delai = Number(config.delai_relance_jours);
const seuil = Number.isFinite(delai) ? delai : 7;
const jours = Math.floor((new Date() - d) / 86400000);
return jours >= seuil ? jours : null;
}
function afficherRelances() {
const banniere = $("relances");
if (!banniere) return;
const liste = prospects
.map(p => ({ p, j: joursDeRelance(p) }))
.filter(x => x.j !== null)
.sort((a, b) => b.j - a.j);
if (!liste.length) { banniere.style.display = "none"; banniere.innerHTML = ""; return; }
const seuil = Number(config.delai_relance_jours) || 7;
banniere.style.display = "block";
banniere.innerHTML =
`🔔 <strong>${liste.length} prospect(s) à relancer</strong> ` +
`<span class="sous">(contactés il y a plus de ${seuil} jours sans réponse)</span>` +
`<div class="relance-chips">${liste.map(({ p, j }) =>
`<button class="chip-relance" data-index="${p.index}">${echap(p["Nom du prospect"] || "Sans nom")} · ${j} j</button>`
).join("")}</div>`;
banniere.querySelectorAll(".chip-relance").forEach(b =>
b.addEventListener("click", () => ouvrirFiche(+b.dataset.index)));
}
// Réinitialise les filtres, déplie la fiche du prospect et la fait défiler à l'écran.
function ouvrirFiche(index) {
$("recherche").value = "";
$("filtre-statut").value = "";
afficherListe();
const carteEl = $("liste").querySelector(`.carte[data-index="${index}"]`);
if (!carteEl) return;
const detail = carteEl.querySelector(".carte-detail");
if (detail.style.display === "none") carteEl.querySelector(".carte-tete").click();
carteEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
function carte(p) { function carte(p) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "carte"; div.className = "carte";
div.dataset.index = p.index;
const tete = document.createElement("div"); const tete = document.createElement("div");
tete.className = "carte-tete"; tete.className = "carte-tete";
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · "); 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] || "#7f8c8d";
const relance = joursDeRelance(p);
const contacte = (p["Date de contact"] || "").trim() 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>` : ""; ? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
tete.innerHTML = ` tete.innerHTML = `
@@ -432,9 +525,11 @@ function carte(p) {
</div> </div>
<div class="carte-infos"> <div class="carte-infos">
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span> <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>` : ""} ${lieu ? `<span class="sous">📍 ${echap(lieu)}</span>` : ""}
${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""} ${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""}
${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""} ${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""}
${(p["Notes"] || "").trim() ? `<span class="sous" title="${echap(p["Notes"])}">📝 note</span>` : ""}
${contacte} ${contacte}
</div>`; </div>`;
@@ -486,7 +581,7 @@ function construireDetail(detail, p) {
enregistrer.textContent = "Enregistrer"; enregistrer.textContent = "Enregistrer";
enregistrer.addEventListener("click", async () => { enregistrer.addEventListener("click", async () => {
const donnees = {}; const donnees = {};
for (const ctrl of grille.querySelectorAll("input, select")) donnees[ctrl.name] = ctrl.value; for (const ctrl of grille.querySelectorAll("input, select, textarea")) donnees[ctrl.name] = ctrl.value;
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" },
@@ -548,6 +643,41 @@ function construireDetail(detail, p) {
$("recherche").addEventListener("input", afficherListe); $("recherche").addEventListener("input", afficherListe);
$("filtre-statut").addEventListener("change", afficherListe); $("filtre-statut").addEventListener("change", afficherListe);
// --- Journal de scraping ---
const COULEURS_LOG = { ok: "#27ae60", ajoute: "#27ae60", vide: "#f39c12", doublon: "#7f8c8d" };
async function chargerLogs() {
let entrees = [];
try { entrees = await (await fetch("/api/logs")).json(); } catch { return; }
const div = $("journal");
if (!entrees.length) {
div.innerHTML = '<p class="vide">Aucune analyse enregistrée pour le moment.</p>';
return;
}
div.innerHTML = entrees.map(e => {
const couleur = COULEURS_LOG[e.statut] || "#c0392b";
return `<div class="ligne-progres">
<span class="sous">${echap(e.date)}</span>
<span class="badge-statut" style="background:${couleur}">${echap(e.statut)}</span>
${echap(e.url)} <span class="sous">${echap(e.message)}</span>
</div>`;
}).join("");
}
$("journal-details").addEventListener("toggle", (e) => { if (e.target.open) chargerLogs(); });
$("journal-rafraichir").addEventListener("click", chargerLogs);
$("journal-vider").addEventListener("click", async () => {
if (!confirm("Vider tout le journal de scraping ?")) return;
await fetch("/api/logs", { method: "DELETE" });
chargerLogs();
});
// Rafraîchit le journal s'il est ouvert (après une analyse ou un import).
function rafraichirLogsSiOuvert() {
if ($("journal-details").open) chargerLogs();
}
remplirFiltreStatut(); remplirFiltreStatut();
chargerConfig(); chargerConfig();
chargerListe(); chargerListe();