import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, CheckCircle2, Database, Network, Clock, } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge, type Ton } from "@/components/ui/badge"; import { Input, Label, SearchInput } from "@/components/ui/form-controls"; import { Segmented, type TabItem } from "@/components/ui/tabs"; import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { StatCard } from "@/components/ui/stat-card"; import { ErrorState, Skeleton } from "@/components/ui/states"; import { Tooltip } from "@/components/ui/tooltip"; import { confirmer, toast } from "@/components/ui/feedback"; import { fmtEntier } from "@/lib/format"; 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; } type Filtre = "tous" | "actifs" | "inactifs" | "echecs"; /** Libellés des deux sources, identiques partout dans la page. */ const VENTES = "Ventes (base FF)"; const RESEAU = "Réseau (Qlik)"; const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "Jamais"; function fmtDuree(ms: number | null): string { if (ms == null) return ""; if (ms < 1000) return `${ms} ms`; const s = Math.round(ms / 1000); return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`; } const STATUTS: Record = { succes: { ton: "succes", libelle: "OK", aide: "Dernière mise à jour réussie" }, echec: { ton: "erreur", libelle: "Échec", aide: "La dernière mise à jour a échoué" }, vide: { ton: "alerte", libelle: "Vide", aide: "Aucune donnée reçue lors de la dernière mise à jour" }, }; function StatutBadge({ statut }: { statut: string | null }) { if (!statut) return null; const s = STATUTS[statut]; return s ? {s.libelle} : {statut}; } const CASE = "h-4 w-4 cursor-pointer accent-[var(--accent)]"; function correspond(r: FournisseurRow, f: Filtre): boolean { if (f === "actifs") return r.actifSql || r.actifQlik; if (f === "inactifs") return !r.actifSql && !r.actifQlik; if (f === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec"; return true; } /** « Lancement automatique » (planifié la nuit) ou « manuel » (bouton). */ function libelleOrigine(origine: SyncState["origine"]): string { return origine === "auto" ? " automatique" : origine === "manuel" ? " manuel" : ""; } 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"); const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null); // ── 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]); const reessayer = () => { setChargement(true); rafraichirTout(); }; // Suivi rapproché pendant un lancement : l'état change à chaque fournisseur. useEffect(() => { if (!state?.enCours) return; const id = setInterval(rafraichirEtat, 3000); return () => clearInterval(id); }, [state?.enCours, rafraichirEtat]); // Saisie numérique pas encore envoyée (cf. majSettingsDifferee). const patchEnAttente = useRef>({}); const minuteurPatch = useRef | null>(null); const majSettings = async (patch: Partial) => { 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}`); // Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse. setSettings({ ...d.settings, ...patchEnAttente.current }); } catch (e) { toast.erreur(`Le réglage n'a pas pu être enregistré : ${e instanceof Error ? e.message : String(e)}`); } }; // Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur // est regroupé 600 ms après la dernière frappe. Avant, chaque chiffre tapé // partait en PATCH, et une réponse tardive pouvait écraser la saisie en cours. const majSettingsDifferee = (patch: Partial) => { setSettings((prev) => (prev ? { ...prev, ...patch } : prev)); patchEnAttente.current = { ...patchEnAttente.current, ...patch }; if (minuteurPatch.current) clearTimeout(minuteurPatch.current); minuteurPatch.current = setTimeout(() => { const aEnvoyer = patchEnAttente.current; patchEnAttente.current = {}; minuteurPatch.current = null; void majSettings(aEnvoyer); }, 600); }; const action = async (act: "start" | "stop" | "seed", forcer = false) => { setActionEnCours(act); 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}`); if (act === "seed") { toast.succes(`Liste des fournisseurs actualisée (${fmtEntier(Number(d.fournisseurs) || 0)} dans le référentiel FF).`); } else if (act === "stop") { toast.info("Arrêt demandé : la mise à jour s'arrête après le fournisseur en cours."); } rafraichirTout(); } catch (e) { const libelles = { start: "Le lancement a échoué", stop: "L'arrêt a échoué", seed: "La liste n'a pas pu être actualisée" }; toast.erreur(`${libelles[act]} : ${e instanceof Error ? e.message : String(e)}`); } finally { setActionEnCours(null); } }; const lancer = async () => { if (!dansLaFenetre) { const ok = await confirmer({ titre: "Lancer la mise à jour maintenant ?", message: "Nous sommes en dehors de la plage horaire nocturne : la mise à jour va solliciter la base FF et Qlik en pleine journée, ce qui peut ralentir l'application pour les magasins.", libelleConfirmer: "Lancer maintenant", }); if (!ok) return; } void action("start", !dansLaFenetre); }; const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => { if (codes.length === 0) return; // 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) { toast.erreur(`La modification n'a pas pu être enregistrée : ${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; return correspond(r, filtre); }); }, [rows, recherche, filtre]); const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]); const basculerEnMasse = async (champ: "actifSql" | "actifQlik", valeur: boolean) => { const n = codesVisibles.length; if (n === 0) return; if (!valeur) { const quoi = champ === "actifSql" ? "des ventes (base FF)" : "des données du réseau (Qlik)"; const ok = await confirmer({ titre: `Désactiver la mise à jour ${quoi} ?`, message: `${fmtEntier(n)} fournisseur${n > 1 ? "s" : ""} de la liste affichée ne ser${n > 1 ? "ont" : "a"} plus mis à jour la nuit pour cette source. Vous pourrez les réactiver à tout moment.`, libelleConfirmer: "Désactiver", danger: true, }); if (!ok) return; } void basculer(codesVisibles, champ, valeur); }; const filtres: readonly TabItem[] = useMemo(() => [ { value: "tous", label: "Tous", count: rows.length }, { value: "actifs", label: "Actifs", count: rows.filter((r) => correspond(r, "actifs")).length }, { value: "inactifs", label: "Inactifs", count: rows.filter((r) => correspond(r, "inactifs")).length }, { value: "echecs", label: "En échec", count: rows.filter((r) => correspond(r, "echecs")).length }, ], [rows]); const colonnes: DataColumn[] = [ { id: "fournisseur", header: "Fournisseur", sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur, cell: (r) => (
{r.nomFournisseur ?? r.codeFournisseur}
{r.codeFournisseur}
), }, { id: "actifSql", header: VENTES, hint: "Cochez pour mettre à jour chaque nuit les ventes de ce fournisseur dans nos magasins.", align: "center", sortValue: (r) => (r.actifSql ? 1 : 0), cell: (r) => ( basculer([r.codeFournisseur], "actifSql", e.target.checked)} /> ), }, { id: "dernierSql", header: "Dernière mise à jour des ventes", sortValue: (r) => r.dernierSqlAt, cell: (r) => (
{fmtDate(r.dernierSqlAt)}
{r.dernierSqlLignes != null && (
{fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}}
)}
), }, { id: "actifQlik", header: RESEAU, hint: "Cochez pour mettre à jour les ventes de ce fournisseur dans les magasins du réseau. Ces extractions sont longues : leur nombre par nuit est plafonné.", align: "center", sortValue: (r) => (r.actifQlik ? 1 : 0), cell: (r) => ( basculer([r.codeFournisseur], "actifQlik", e.target.checked)} /> ), }, { id: "dernierQlik", header: "Dernière mise à jour réseau", sortValue: (r) => r.dernierQlikAt, cell: (r) => (
{fmtDate(r.dernierQlikAt)}
{r.dernierQlikCodes != null && (
{fmtEntier(r.dernierQlikCodes)} produits
)}
), }, { id: "remarque", header: "Remarque", cell: (r) => (r.desactiveMotif ? {r.desactiveMotif} : null), }, ]; if (chargement) { return (
); } if (!settings && erreur) { return (
Réessayer} />
); } return (
action("stop")} disabled={actionEnCours === "stop"} className="border-[var(--accent-error)] text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"> {actionEnCours === "stop" ? : } Arrêter ) : ( )} /> {erreur && (
Les informations n'ont pas pu être actualisées : {erreur}
)} {/* ── Réglages ── */} {settings && (
de majSettingsDifferee({ heureDebut: Number(e.target.value) })} className="w-16 text-center tabular-nums" /> h à majSettingsDifferee({ heureFin: Number(e.target.value) })} className="w-16 text-center tabular-nums" /> h

Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout.

majSettingsDifferee({ qlikParNuit: Number(e.target.value) })} className="w-20 text-center tabular-nums" />

Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit.

Actualiser les données réseau de plus de majSettingsDifferee({ qlikMinJours: Number(e.target.value) })} className="w-20 text-center tabular-nums" /> jours

Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien. Sont aussi refaits, sans attendre ce délai : les données d'avant le mois en cours (le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.

Les modifications sont enregistrées automatiquement.

)} {/* ── Lancement en cours ou dernier lancement ── */} {state && (
{state.enCours ? : state.finAt ? : } {state.enCours ? `Lancement${libelleOrigine(state.origine)} en cours` : state.finAt ? `Dernier lancement${libelleOrigine(state.origine)} terminé` : "Aucun lancement depuis le démarrage du serveur"} {state.enCours && state.fournisseurCourant && ( : {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant} )} {state.tourTermine && ( Tous les fournisseurs ont été traités )}
{(state.enCours || state.finAt) && (
{VENTES} : {fmtEntier(state.sqlFaits)} à jour {state.sqlEchecs > 0 && , {fmtEntier(state.sqlEchecs)} en échec} {RESEAU} : {fmtEntier(state.qlikFaits)} à jour {state.qlikEchecs > 0 && , {fmtEntier(state.qlikEchecs)} en échec} {state.debutAt && Commencé le {fmtDate(state.debutAt)}} {!state.enCours && state.finAt && Terminé le {fmtDate(state.finAt)}}
)} {state.derniereErreur && (

Dernière erreur : {state.derniereErreur}

)}

{dansLaFenetre ? "Nous sommes actuellement dans la plage horaire nocturne." : "Hors de la plage horaire : « Lancer maintenant » démarre quand même une mise à jour immédiate."}

)} {/* ── Résumé ── */} {resume && (
Retirés automatiquement } value={fmtEntier(resume.desactivesAuto)} hint="aucun article dans la base FF" />
)} {/* ── Fournisseurs ── */} action("seed")} disabled={actionEnCours === "seed"} title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms"> {actionEnCours === "seed" ? : } Actualiser la liste des fournisseurs } />
Pour les {fmtEntier(visibles.length)} fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous :
r.codeFournisseur} pageSize={0} unite="fournisseurs" toolbar={ <> } emptyTitle={rows.length === 0 ? "Aucun fournisseur" : "Aucun fournisseur ne correspond"} emptyDescription={rows.length === 0 ? "Utilisez « Actualiser la liste des fournisseurs » pour importer le référentiel FF." : "Modifiez la recherche ou choisissez « Tous »."} />
); } const DESCRIPTION = "Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik).";