"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, CheckCircle, Database, Network, Search, } from "lucide-react"; import { cn } from "@/lib/utils"; interface SyncSettings { actif: boolean; heureDebut: number; heureFin: number; qlikParNuit: number; sqlParQlik: number; qlikMinJours: number; } interface SyncState { enCours: boolean; origine: "auto" | "manuel" | null; debutAt: string | null; finAt: string | null; fournisseurCourant: string | null; etape: "sql" | "qlik" | null; sqlFaits: number; sqlEchecs: number; qlikFaits: number; qlikEchecs: number; tourTermine: boolean; derniereErreur: string | null; } interface FournisseurRow { codeFournisseur: string; nomFournisseur: string | null; actifSql: boolean; actifQlik: boolean; desactiveMotif: string | null; dernierSqlAt: string | null; dernierSqlStatut: string | null; dernierSqlLignes: number | null; dernierSqlMs: number | null; dernierQlikAt: string | null; dernierQlikStatut: string | null; dernierQlikCodes: number | null; } interface Resume { total: number; actifsSql: number; actifsQlik: number; desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number; } const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais"; const fmtDuree = (ms: number | null) => ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`; function Pastille({ statut }: { statut: string | null }) { if (!statut) return —; const couleurs: Record = { succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500", }; const libelles: Record = { succes: "ok", echec: "échec", vide: "vide" }; return {libelles[statut] ?? statut}; } export function SynchronisationClient() { const [settings, setSettings] = useState(null); const [state, setState] = useState(null); const [dansLaFenetre, setDansLaFenetre] = useState(false); const [rows, setRows] = useState([]); const [resume, setResume] = useState(null); const [erreur, setErreur] = useState(null); const [chargement, setChargement] = useState(true); const [recherche, setRecherche] = useState(""); const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous"); // ── Lectures pures : aucun setState, pour rester appelables depuis un effet ── const lireEtat = useCallback(async () => { const res = await fetch("/api/admin/sync"); if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); return res.json(); }, []); const lireFournisseurs = useCallback(async () => { const res = await fetch("/api/admin/sync/fournisseurs"); if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); return res.json(); }, []); const rafraichirEtat = useCallback(() => { lireEtat() .then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); }) .catch((e) => setErreur(e instanceof Error ? e.message : String(e))); }, [lireEtat]); const rafraichirTout = useCallback(() => { Promise.all([lireEtat(), lireFournisseurs()]) .then(([e, f]) => { setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre); setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null); }) .catch((e) => setErreur(e instanceof Error ? e.message : String(e))) .finally(() => setChargement(false)); }, [lireEtat, lireFournisseurs]); useEffect(() => { rafraichirTout(); }, [rafraichirTout]); // Suivi rapproché pendant un round : l'état change à chaque fournisseur. useEffect(() => { if (!state?.enCours) return; const id = setInterval(rafraichirEtat, 3000); return () => clearInterval(id); }, [state?.enCours, rafraichirEtat]); const majSettings = async (patch: Partial) => { setErreur(null); try { const res = await fetch("/api/admin/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); const d = await res.json(); if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); setSettings(d.settings); } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } }; const action = async (act: "start" | "stop" | "seed", forcer = false) => { setErreur(null); try { const res = await fetch("/api/admin/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }), }); const d = await res.json(); if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); rafraichirTout(); } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } }; const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => { setErreur(null); // Mise à jour optimiste : la liste peut compter des centaines de lignes, // un rechargement complet à chaque case cochée serait pénible. setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur) ? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) } : r))); try { const res = await fetch("/api/admin/sync/fournisseurs", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ codes, [champ]: valeur }), }); if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); rafraichirTout(); // l'optimisme était infondé : on resynchronise } }; const visibles = useMemo(() => { const q = recherche.trim().toLowerCase(); return rows.filter((r) => { if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false; if (filtre === "actifs") return r.actifSql || r.actifQlik; if (filtre === "inactifs") return !r.actifSql && !r.actifQlik; if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec"; return true; }); }, [rows, recherche, filtre]); const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]); return (

Synchronisation nocturne

Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre se referme avant la fin, la nuit suivante reprend là où on s'est arrêté ; quand tout le monde est passé, un nouveau tour recommence.

{erreur && (
{erreur}
)} {/* ── Réglages ── */} {settings && (
majSettings({ heureDebut: Number(e.target.value) })} className="w-16 rounded-lg px-2 py-1.5 text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
h à
majSettings({ heureFin: Number(e.target.value) })} className="w-16 rounded-lg px-2 py-1.5 text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
h
majSettings({ qlikParNuit: Number(e.target.value) })} className="w-20 rounded-lg px-2 py-1.5 text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
majSettings({ qlikMinJours: Number(e.target.value) })} className="w-20 rounded-lg px-2 py-1.5 text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
{state?.enCours ? ( ) : ( )}

{dansLaFenetre ? "Nous sommes actuellement dans la fenêtre." : "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."} {" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu'elle monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin.

)} {/* ── Round en cours ── */} {state && (state.enCours || state.finAt) && (
{state.enCours ? : } {state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"} {state.enCours && state.fournisseurCourant && ( — {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant} )}
SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`} Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`} {state.debutAt && Début {fmtDate(state.debutAt)}} {state.tourTermine && Tour bouclé — tout le monde est à jour}
{state.derniereErreur && (

Dernière erreur : {state.derniereErreur}

)}
)} {/* ── Résumé ── */} {resume && (
{[ { l: "Fournisseurs", v: resume.total }, { l: "Actifs SQL", v: resume.actifsSql }, { l: "Actifs Qlik", v: resume.actifsQlik }, { l: "Jamais synchronisés", v: resume.jamaisSql }, { l: "Désactivés auto", v: resume.desactivesAuto }, { l: "En échec", v: resume.enEchecSql + resume.enEchecQlik }, ].map((s) => ( {s.l} {s.v} ))}
)} {/* ── Filtres et actions de masse ── */}
setRecherche(e.target.value)} placeholder="Code ou nom" className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => ( ))}
{visibles.length} affichés :
{/* ── Tableau ── */}
{chargement ? (
Chargement…
) : visibles.length === 0 ? (
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
) : (
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => ( ))} {visibles.map((r) => ( ))}
{h}
{r.nomFournisseur ?? r.codeFournisseur}
{r.codeFournisseur}
basculer([r.codeFournisseur], "actifSql", e.target.checked)} /> {" "} {fmtDate(r.dernierSqlAt)} {r.dernierSqlLignes != null && · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}} basculer([r.codeFournisseur], "actifQlik", e.target.checked)} /> {" "} {fmtDate(r.dernierQlikAt)} {r.dernierQlikCodes != null && · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes} {r.desactiveMotif}
)}
); }