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/
prospects.csv
config.json
backups/
scrape.log
__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.
- **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
View File

@@ -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
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; }
.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();