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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,4 +2,6 @@
|
||||
fb_profile/
|
||||
prospects.csv
|
||||
config.json
|
||||
backups/
|
||||
scrape.log
|
||||
__pycache__/
|
||||
|
||||
12
README.md
12
README.md
@@ -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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
|
||||
- ⚠️ 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
92
app.py
@@ -5,11 +5,12 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import webbrowser
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
|
||||
|
||||
import excel
|
||||
import extractor
|
||||
import scraper
|
||||
import trajet
|
||||
@@ -17,10 +18,13 @@ import trajet
|
||||
DOSSIER = Path(__file__).parent
|
||||
CSV_PATH = DOSSIER / "prospects.csv"
|
||||
CONFIG_PATH = DOSSIER / "config.json"
|
||||
BACKUP_DIR = DOSSIER / "backups"
|
||||
JOURNAL_PATH = DOSSIER / "scrape.log"
|
||||
MAX_BACKUPS = 30
|
||||
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",
|
||||
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes",
|
||||
]
|
||||
STATUT_DEFAUT = "À contacter"
|
||||
|
||||
@@ -29,6 +33,7 @@ CONFIG_DEFAUT = {
|
||||
"conso_l_100km": 6.5,
|
||||
"prix_carburant": 1.90,
|
||||
"cout_peage_km": 0.10,
|
||||
"delai_relance_jours": 7,
|
||||
"modele_message": (
|
||||
"Bonjour,\n\n"
|
||||
"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]
|
||||
|
||||
|
||||
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):
|
||||
_sauvegarder_csv()
|
||||
tmp = CSV_PATH.with_suffix(".csv.tmp")
|
||||
with open(tmp, "w", encoding="utf-8-sig", newline="") as f:
|
||||
ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore")
|
||||
@@ -62,6 +90,17 @@ def ecrire_prospects(lignes):
|
||||
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():
|
||||
config = dict(CONFIG_DEFAUT)
|
||||
if CONFIG_PATH.exists():
|
||||
@@ -112,11 +151,16 @@ def api_scrape():
|
||||
try:
|
||||
resultat = scraper.scrape(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)
|
||||
except scraper.ErreurScrape as e:
|
||||
journaliser(url, e.code, str(e))
|
||||
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)
|
||||
except Exception as e:
|
||||
journaliser(url, "erreur", str(e))
|
||||
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
|
||||
finally:
|
||||
verrou_scrape.release()
|
||||
@@ -174,6 +218,42 @@ def api_export():
|
||||
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):
|
||||
"""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"]
|
||||
if not resultat["ok"]:
|
||||
journaliser(url, resultat.get("code", "erreur"), resultat.get("message", "Échec."))
|
||||
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
|
||||
champs = extractor.extraire(
|
||||
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
|
||||
@@ -188,10 +269,12 @@ def _ajouter_resultat_lot(resultat):
|
||||
lignes = lire_prospects()
|
||||
lien = (champs.get("Lien Facebook") or "").strip()
|
||||
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"]}
|
||||
champs["Statut"] = STATUT_DEFAUT
|
||||
lignes.append({col: champs.get(col, "") for col in COLONNES})
|
||||
ecrire_prospects(lignes)
|
||||
journaliser(url, "ajoute", 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(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
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)
|
||||
return jsonify({"ok": True, "config": config})
|
||||
|
||||
|
||||
78
excel.py
Normal file
78
excel.py
Normal 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()
|
||||
@@ -28,7 +28,12 @@
|
||||
#formulaire { display: none; margin-top: 16px; }
|
||||
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
|
||||
.champ input, .champ select { 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; }
|
||||
.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; }
|
||||
@@ -80,6 +85,10 @@
|
||||
<label>Tarif péage (€ / km d'autoroute)</label>
|
||||
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
|
||||
</div>
|
||||
<div class="champ">
|
||||
<label>Délai de relance (jours sans réponse)</label>
|
||||
<input id="cfg-relance" type="number" step="1" min="0" placeholder="7">
|
||||
</div>
|
||||
</div>
|
||||
<p class="indice">Le péage est estimé à partir des kilomètres d'autoroute du trajet × ce tarif (≈ 0,10 €/km pour une voiture). Mettez 0 pour ne pas compter de péage.</p>
|
||||
<div class="champ" style="margin-top:12px">
|
||||
@@ -123,9 +132,13 @@
|
||||
</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>
|
||||
<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 class="filtres">
|
||||
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
|
||||
@@ -134,10 +147,25 @@
|
||||
<div id="liste"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details id="journal-details">
|
||||
<summary style="cursor:pointer;font-weight:600;font-size:16px">🩺 Journal de scraping</summary>
|
||||
<p class="indice">Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).</p>
|
||||
<div class="actions" style="margin-top:0">
|
||||
<button type="button" class="secondaire" id="journal-rafraichir">Rafraîchir</button>
|
||||
<button type="button" class="danger" id="journal-vider">Vider le journal</button>
|
||||
</div>
|
||||
<div id="journal" style="margin-top:12px"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
const COLONNES = ["Nom du prospect","Statut","Département","Ville","Code Postal","Adresse","Date d'ajout",
|
||||
"Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook"];
|
||||
"Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook","Notes"];
|
||||
|
||||
// Statuts encore « actifs » pour lesquels une relance a du sens.
|
||||
const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
|
||||
|
||||
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
|
||||
const STATUTS = {
|
||||
@@ -179,7 +207,9 @@ async function chargerConfig() {
|
||||
$("cfg-conso").value = config.conso_l_100km ?? "";
|
||||
$("cfg-prix").value = config.prix_carburant ?? "";
|
||||
$("cfg-peage").value = config.cout_peage_km ?? "";
|
||||
$("cfg-relance").value = config.delai_relance_jours ?? "";
|
||||
$("cfg-message").value = config.modele_message || "";
|
||||
afficherRelances();
|
||||
}
|
||||
|
||||
$("cfg-enregistrer").addEventListener("click", async () => {
|
||||
@@ -188,6 +218,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
|
||||
conso_l_100km: $("cfg-conso").value,
|
||||
prix_carburant: $("cfg-prix").value,
|
||||
cout_peage_km: $("cfg-peage").value,
|
||||
delai_relance_jours: $("cfg-relance").value,
|
||||
modele_message: $("cfg-message").value,
|
||||
};
|
||||
const rep = await fetch("/api/config", {
|
||||
@@ -198,6 +229,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
|
||||
if (rep.ok) {
|
||||
config = (await rep.json()).config;
|
||||
message("Paramètres enregistrés ✔", "info", "message-config");
|
||||
afficherRelances();
|
||||
} else {
|
||||
message("Échec de l'enregistrement.", "erreur", "message-config");
|
||||
}
|
||||
@@ -228,6 +260,12 @@ function champControle(col, valeur) {
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
if (col === "Notes") {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.name = col; ta.rows = 3; ta.value = valeur || "";
|
||||
ta.placeholder = "Notes libres : compte-rendu d'appel, contraintes, disponibilités…";
|
||||
return ta;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.name = col;
|
||||
input.value = valeur || "";
|
||||
@@ -239,7 +277,7 @@ function construireGrilleChamps(grille, valeurs) {
|
||||
grille.innerHTML = "";
|
||||
for (const col of COLONNES) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "champ";
|
||||
div.className = "champ" + (col === "Notes" ? " large" : "");
|
||||
const label = document.createElement("label");
|
||||
label.textContent = col;
|
||||
div.append(label, champControle(col, valeurs[col]));
|
||||
@@ -293,13 +331,14 @@ $("analyser").addEventListener("click", async () => {
|
||||
} finally {
|
||||
bouton.disabled = false;
|
||||
bouton.textContent = "Analyser";
|
||||
rafraichirLogsSiOuvert();
|
||||
}
|
||||
});
|
||||
|
||||
$("formulaire").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const ligne = {};
|
||||
for (const ctrl of $("grille-champs").querySelectorAll("input, select")) 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", {
|
||||
method: "POST",
|
||||
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");
|
||||
chargerListe();
|
||||
rafraichirLogsSiOuvert();
|
||||
} catch {
|
||||
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
|
||||
} finally {
|
||||
@@ -395,6 +435,7 @@ function remplirFiltreStatut() {
|
||||
async function chargerListe() {
|
||||
prospects = await (await fetch("/api/prospects")).json();
|
||||
afficherListe();
|
||||
afficherRelances();
|
||||
}
|
||||
|
||||
function afficherListe() {
|
||||
@@ -414,15 +455,67 @@ function afficherListe() {
|
||||
for (const p of visibles) liste.append(carte(p));
|
||||
}
|
||||
|
||||
// --- Relances ---
|
||||
|
||||
function parseDateFr(s) {
|
||||
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec((s || "").trim());
|
||||
return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
|
||||
}
|
||||
|
||||
// Nombre de jours depuis le contact si le prospect est à relancer, sinon null.
|
||||
function joursDeRelance(p) {
|
||||
if (!STATUTS_A_RELANCER.includes(statutDe(p))) return null;
|
||||
const d = parseDateFr(p["Date de contact"]);
|
||||
if (!d) return null;
|
||||
const delai = Number(config.delai_relance_jours);
|
||||
const seuil = Number.isFinite(delai) ? delai : 7;
|
||||
const jours = Math.floor((new Date() - d) / 86400000);
|
||||
return jours >= seuil ? jours : null;
|
||||
}
|
||||
|
||||
function afficherRelances() {
|
||||
const banniere = $("relances");
|
||||
if (!banniere) return;
|
||||
const liste = prospects
|
||||
.map(p => ({ p, j: joursDeRelance(p) }))
|
||||
.filter(x => x.j !== null)
|
||||
.sort((a, b) => b.j - a.j);
|
||||
if (!liste.length) { banniere.style.display = "none"; banniere.innerHTML = ""; return; }
|
||||
const seuil = Number(config.delai_relance_jours) || 7;
|
||||
banniere.style.display = "block";
|
||||
banniere.innerHTML =
|
||||
`🔔 <strong>${liste.length} prospect(s) à relancer</strong> ` +
|
||||
`<span class="sous">(contactés il y a plus de ${seuil} jours sans réponse)</span>` +
|
||||
`<div class="relance-chips">${liste.map(({ p, j }) =>
|
||||
`<button class="chip-relance" data-index="${p.index}">${echap(p["Nom du prospect"] || "Sans nom")} · ${j} j</button>`
|
||||
).join("")}</div>`;
|
||||
banniere.querySelectorAll(".chip-relance").forEach(b =>
|
||||
b.addEventListener("click", () => ouvrirFiche(+b.dataset.index)));
|
||||
}
|
||||
|
||||
// Réinitialise les filtres, déplie la fiche du prospect et la fait défiler à l'écran.
|
||||
function ouvrirFiche(index) {
|
||||
$("recherche").value = "";
|
||||
$("filtre-statut").value = "";
|
||||
afficherListe();
|
||||
const carteEl = $("liste").querySelector(`.carte[data-index="${index}"]`);
|
||||
if (!carteEl) return;
|
||||
const detail = carteEl.querySelector(".carte-detail");
|
||||
if (detail.style.display === "none") carteEl.querySelector(".carte-tete").click();
|
||||
carteEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function carte(p) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "carte";
|
||||
div.dataset.index = p.index;
|
||||
|
||||
const tete = document.createElement("div");
|
||||
tete.className = "carte-tete";
|
||||
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
|
||||
const st = statutDe(p);
|
||||
const couleur = STATUTS[st] || "#7f8c8d";
|
||||
const relance = joursDeRelance(p);
|
||||
const contacte = (p["Date de contact"] || "").trim()
|
||||
? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
|
||||
tete.innerHTML = `
|
||||
@@ -432,9 +525,11 @@ function carte(p) {
|
||||
</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>`;
|
||||
|
||||
@@ -486,7 +581,7 @@ function construireDetail(detail, p) {
|
||||
enregistrer.textContent = "Enregistrer";
|
||||
enregistrer.addEventListener("click", async () => {
|
||||
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}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -548,6 +643,41 @@ function construireDetail(detail, p) {
|
||||
$("recherche").addEventListener("input", afficherListe);
|
||||
$("filtre-statut").addEventListener("change", afficherListe);
|
||||
|
||||
// --- Journal de scraping ---
|
||||
|
||||
const COULEURS_LOG = { ok: "#27ae60", ajoute: "#27ae60", vide: "#f39c12", doublon: "#7f8c8d" };
|
||||
|
||||
async function chargerLogs() {
|
||||
let entrees = [];
|
||||
try { entrees = await (await fetch("/api/logs")).json(); } catch { return; }
|
||||
const div = $("journal");
|
||||
if (!entrees.length) {
|
||||
div.innerHTML = '<p class="vide">Aucune analyse enregistrée pour le moment.</p>';
|
||||
return;
|
||||
}
|
||||
div.innerHTML = entrees.map(e => {
|
||||
const couleur = COULEURS_LOG[e.statut] || "#c0392b";
|
||||
return `<div class="ligne-progres">
|
||||
<span class="sous">${echap(e.date)}</span>
|
||||
<span class="badge-statut" style="background:${couleur}">${echap(e.statut)}</span>
|
||||
${echap(e.url)} <span class="sous">${echap(e.message)}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
$("journal-details").addEventListener("toggle", (e) => { if (e.target.open) chargerLogs(); });
|
||||
$("journal-rafraichir").addEventListener("click", chargerLogs);
|
||||
$("journal-vider").addEventListener("click", async () => {
|
||||
if (!confirm("Vider tout le journal de scraping ?")) return;
|
||||
await fetch("/api/logs", { method: "DELETE" });
|
||||
chargerLogs();
|
||||
});
|
||||
|
||||
// Rafraîchit le journal s'il est ouvert (après une analyse ou un import).
|
||||
function rafraichirLogsSiOuvert() {
|
||||
if ($("journal-details").open) chargerLogs();
|
||||
}
|
||||
|
||||
remplirFiltreStatut();
|
||||
chargerConfig();
|
||||
chargerListe();
|
||||
|
||||
Reference in New Issue
Block a user