mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 14:38:10 +02:00
feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair
Chaque page reprend le socle du lot 2 : en-tête avec une phrase d'explication, couleurs du thème (plus de pages figées en blanc), textes de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par toast / confirmation, états vides et d'erreur expliqués. - Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10 avec lien vers la fiche produit. - Révision d'assortiment : un seul endroit pour enregistrer et exporter (menu Export unique, mêmes droits pour tous), annulation confirmée, sens des gammes affiché partout, magasin choisi en un clic, colonnes nommées en clair, bandeau de progression au lieu de la fenêtre floue, droits lus côté serveur, ligne sélectionnée opaque sur les colonnes figées. - Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures ventes, Ventes par mois (dernier mois complet par défaut), Publicités, Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation et Connexion migrés sur DataTable / Tabs / Card. - Le nom de l'utilisateur est transmis à la session (affiché dans l'en-tête). - Composants devenus inutiles supprimés (anciennes fenêtres modales, export-dropdown, financial-cell, supplier-selection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
61 files changed
+6101
-6864
No files matched your search
@@ -3,9 +3,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle, Database, Network, Search,
|
||||
CheckCircle2, Database, Network, Clock,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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;
|
||||
@@ -51,18 +62,46 @@ interface Resume {
|
||||
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`;
|
||||
type Filtre = "tous" | "actifs" | "inactifs" | "echecs";
|
||||
|
||||
function Pastille({ statut }: { statut: string | null }) {
|
||||
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||
const couleurs: Record<string, string> = {
|
||||
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
|
||||
};
|
||||
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
|
||||
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
|
||||
/** 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<string, { ton: Ton; libelle: string; aide: string }> = {
|
||||
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 ? <Badge ton={s.ton} title={s.aide}>{s.libelle}</Badge> : <Badge>{statut}</Badge>;
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -74,7 +113,8 @@ export function SynchronisationClient() {
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const [chargement, setChargement] = useState(true);
|
||||
const [recherche, setRecherche] = useState("");
|
||||
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
|
||||
const [filtre, setFiltre] = useState<Filtre>("tous");
|
||||
const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null);
|
||||
|
||||
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
|
||||
const lireEtat = useCallback(async () => {
|
||||
@@ -107,7 +147,12 @@ export function SynchronisationClient() {
|
||||
|
||||
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
|
||||
|
||||
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
|
||||
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);
|
||||
@@ -119,7 +164,6 @@ export function SynchronisationClient() {
|
||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
|
||||
@@ -128,7 +172,9 @@ export function SynchronisationClient() {
|
||||
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) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
} 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
|
||||
@@ -147,19 +193,41 @@ export function SynchronisationClient() {
|
||||
};
|
||||
|
||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||
setErreur(null);
|
||||
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) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
} 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) => {
|
||||
setErreur(null);
|
||||
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)
|
||||
@@ -172,7 +240,7 @@ export function SynchronisationClient() {
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
} catch (e) {
|
||||
setErreur(e instanceof Error ? e.message : String(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
|
||||
}
|
||||
};
|
||||
@@ -181,254 +249,385 @@ export function SynchronisationClient() {
|
||||
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;
|
||||
return correspond(r, filtre);
|
||||
});
|
||||
}, [rows, recherche, filtre]);
|
||||
|
||||
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-xl">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Synchronisation nocturne
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
|
||||
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.
|
||||
</p>
|
||||
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);
|
||||
};
|
||||
|
||||
{erreur && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
const filtres: readonly TabItem<Filtre>[] = 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]);
|
||||
|
||||
{/* ── Réglages ── */}
|
||||
{settings && (
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.actif}
|
||||
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
|
||||
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
Synchronisation nocturne active
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => majSettingsDifferee({ 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)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => majSettingsDifferee({ 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)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettingsDifferee({ 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)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettingsDifferee({ 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)" }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{state?.enCours ? (
|
||||
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
|
||||
<Square className="w-3.5 h-3.5" /> Arrêter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
|
||||
style={{ background: "var(--accent)" }}>
|
||||
<Play className="w-3.5 h-3.5" /> Lancer maintenant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Round en cours ── */}
|
||||
{state && (state.enCours || state.finAt) && (
|
||||
<div className="rounded-2xl p-4 mb-4 text-[13px]"
|
||||
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{state.enCours
|
||||
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
|
||||
</span>
|
||||
{state.enCours && state.fournisseurCourant && (
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
|
||||
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
|
||||
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
|
||||
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
|
||||
</div>
|
||||
{state.derniereErreur && (
|
||||
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résumé ── */}
|
||||
{resume && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
|
||||
{[
|
||||
{ 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) => (
|
||||
<span key={s.l} className="rounded-lg px-2.5 py-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filtres et actions de masse ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
<input value={recherche} onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFiltre(f)}
|
||||
className="rounded-full px-3 py-1 text-[12px] font-semibold"
|
||||
style={{
|
||||
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filtre === f ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}>
|
||||
{f === "echecs" ? "en échec" : f}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Activer SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver Qlik
|
||||
</button>
|
||||
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
|
||||
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<RefreshCw className="w-3 h-3" /> Actualiser la liste
|
||||
</button>
|
||||
const colonnes: DataColumn<FournisseurRow>[] = [
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur,
|
||||
cell: (r) => (
|
||||
<div className="min-w-[180px]">
|
||||
<div className="font-medium text-[var(--text-primary)]">{r.nomFournisseur ?? r.codeFournisseur}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{r.codeFournisseur}</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
{chargement ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
|
||||
</div>
|
||||
) : visibles.length === 0 ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
|
||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibles.map((r) => (
|
||||
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
{r.nomFournisseur ?? r.codeFournisseur}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierSqlStatut} />{" "}
|
||||
{fmtDate(r.dernierSqlAt)}
|
||||
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierQlikStatut} />{" "}
|
||||
{fmtDate(r.dernierQlikAt)}
|
||||
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.desactiveMotif}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.actifSql}
|
||||
className={CASE}
|
||||
aria-label={`Mettre à jour les ventes de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dernierSql",
|
||||
header: "Dernière mise à jour des ventes",
|
||||
sortValue: (r) => r.dernierSqlAt,
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<StatutBadge statut={r.dernierSqlStatut} />
|
||||
{fmtDate(r.dernierSqlAt)}
|
||||
</div>
|
||||
{r.dernierSqlLignes != null && (
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.actifQlik}
|
||||
className={CASE}
|
||||
aria-label={`Mettre à jour les données réseau de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dernierQlik",
|
||||
header: "Dernière mise à jour réseau",
|
||||
sortValue: (r) => r.dernierQlikAt,
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<StatutBadge statut={r.dernierQlikStatut} />
|
||||
{fmtDate(r.dernierQlikAt)}
|
||||
</div>
|
||||
{r.dernierQlikCodes != null && (
|
||||
<div className="text-xs text-[var(--text-muted)]">{fmtEntier(r.dernierQlikCodes)} produits</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "remarque",
|
||||
header: "Remarque",
|
||||
cell: (r) => (r.desactiveMotif
|
||||
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
|
||||
: null),
|
||||
},
|
||||
];
|
||||
|
||||
if (chargement) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings && erreur) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||
<ErrorState
|
||||
title="Les réglages de la synchronisation n'ont pas pu être chargés"
|
||||
detail={erreur}
|
||||
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl space-y-6">
|
||||
<PageHeader
|
||||
icon={CalendarClock}
|
||||
title="Synchronisation"
|
||||
description={DESCRIPTION}
|
||||
actions={state?.enCours ? (
|
||||
<Button variant="outline" onClick={() => 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" ? <Loader2 className="animate-spin" /> : <Square />}
|
||||
Arrêter
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={lancer} disabled={actionEnCours === "start" || !settings}>
|
||||
{actionEnCours === "start" ? <Loader2 className="animate-spin" /> : <Play />}
|
||||
Lancer maintenant
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{erreur && (
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)] px-4 py-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-[var(--accent-error)]" aria-hidden />
|
||||
<span className="flex-1 text-[var(--text-primary)]">
|
||||
Les informations n'ont pas pu être actualisées : <span className="text-[var(--text-secondary)]">{erreur}</span>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={rafraichirTout}>Réessayer</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Réglages ── */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Réglages"
|
||||
description="Chaque nuit, dans la plage horaire choisie, les fournisseurs sont mis à jour un par un, en commençant par ceux dont les données sont les plus anciennes. Si la nuit ne suffit pas, la suivante reprend là où la précédente s'est arrêtée."
|
||||
/>
|
||||
<CardContent className="space-y-5">
|
||||
<label htmlFor="sync-actif" className="inline-flex cursor-pointer items-center gap-2.5">
|
||||
<input id="sync-actif" type="checkbox" checked={settings.actif}
|
||||
onChange={(e) => majSettings({ actif: e.target.checked })} className={CASE} />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
Synchronisation nocturne activée
|
||||
</span>
|
||||
<Badge ton={settings.actif ? "succes" : "neutre"}>{settings.actif ? "Activée" : "Désactivée"}</Badge>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="sync-debut">Plage horaire</Label>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>de</span>
|
||||
<Input id="sync-debut" type="number" min={0} max={23} value={settings.heureDebut}
|
||||
aria-label="Heure de début"
|
||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 text-center tabular-nums" />
|
||||
<span>h à</span>
|
||||
<Input id="sync-fin" type="number" min={0} max={23} value={settings.heureFin}
|
||||
aria-label="Heure de fin"
|
||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 text-center tabular-nums" />
|
||||
<span>h</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sync-qlik-nuit">Extractions réseau par nuit</Label>
|
||||
<Input id="sync-qlik-nuit" type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 text-center tabular-nums" />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sync-qlik-jours">Fraîcheur des données réseau</Label>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>Actualiser les données réseau de plus de</span>
|
||||
<Input id="sync-qlik-jours" type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 text-center tabular-nums" />
|
||||
<span>jours</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)]">Les modifications sont enregistrées automatiquement.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Lancement en cours ou dernier lancement ── */}
|
||||
{state && (
|
||||
<Card className={state.enCours ? "border-[var(--accent-border)] bg-[var(--accent-bg)]" : undefined}>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{state.enCours
|
||||
? <Loader2 className="h-5 w-5 animate-spin text-[var(--accent)]" aria-hidden />
|
||||
: state.finAt
|
||||
? <CheckCircle2 className="h-5 w-5 text-[var(--accent-success)]" aria-hidden />
|
||||
: <Clock className="h-5 w-5 text-[var(--text-muted)]" aria-hidden />}
|
||||
<span className="text-base font-semibold text-[var(--text-primary)]">
|
||||
{state.enCours
|
||||
? `Lancement${libelleOrigine(state.origine)} en cours`
|
||||
: state.finAt
|
||||
? `Dernier lancement${libelleOrigine(state.origine)} terminé`
|
||||
: "Aucun lancement depuis le démarrage du serveur"}
|
||||
</span>
|
||||
{state.enCours && state.fournisseurCourant && (
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
: {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant}
|
||||
</span>
|
||||
)}
|
||||
{state.tourTermine && (
|
||||
<Badge ton="succes">Tous les fournisseurs ont été traités</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(state.enCours || state.finAt) && (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-[var(--text-secondary)]">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||
{VENTES} : {fmtEntier(state.sqlFaits)} à jour
|
||||
{state.sqlEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.sqlEchecs)} en échec</span>}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Network className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||
{RESEAU} : {fmtEntier(state.qlikFaits)} à jour
|
||||
{state.qlikEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.qlikEchecs)} en échec</span>}
|
||||
</span>
|
||||
{state.debutAt && <span>Commencé le {fmtDate(state.debutAt)}</span>}
|
||||
{!state.enCours && state.finAt && <span>Terminé le {fmtDate(state.finAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.derniereErreur && (
|
||||
<p className="text-[13px] text-[var(--accent-error)]">Dernière erreur : {state.derniereErreur}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{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."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Résumé ── */}
|
||||
{resume && (
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard label="Fournisseurs" value={fmtEntier(resume.total)} />
|
||||
<StatCard label={`${VENTES} activées`} value={fmtEntier(resume.actifsSql)} />
|
||||
<StatCard label={`${RESEAU} activé`} value={fmtEntier(resume.actifsQlik)} />
|
||||
<StatCard label="Ventes jamais mises à jour" value={fmtEntier(resume.jamaisSql)} />
|
||||
<StatCard
|
||||
label={
|
||||
<Tooltip content="Fournisseurs sans aucun article dans la base FF : ils ont été retirés de la mise à jour nocturne. Cochez-les à nouveau pour les réintégrer.">
|
||||
<span tabIndex={0} className="cursor-help underline decoration-dotted underline-offset-2">Retirés automatiquement</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={fmtEntier(resume.desactivesAuto)}
|
||||
hint="aucun article dans la base FF"
|
||||
/>
|
||||
<StatCard
|
||||
label="En échec"
|
||||
value={fmtEntier(resume.enEchecSql + resume.enEchecQlik)}
|
||||
hint={`${fmtEntier(resume.enEchecSql)} ventes, ${fmtEntier(resume.enEchecQlik)} réseau`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fournisseurs ── */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Fournisseurs"
|
||||
description="Cochez, pour chaque fournisseur, les données à mettre à jour chaque nuit."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => action("seed")} disabled={actionEnCours === "seed"}
|
||||
title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms">
|
||||
{actionEnCours === "seed" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Actualiser la liste des fournisseurs
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2">
|
||||
<span className="text-[13px] text-[var(--text-secondary)]">
|
||||
Pour les <strong className="text-[var(--text-primary)]">{fmtEntier(visibles.length)}</strong> fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous :
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifSql", true)}>
|
||||
Activer les ventes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifSql", false)}>
|
||||
Désactiver les ventes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifQlik", false)}>
|
||||
Désactiver le réseau
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
rows={visibles}
|
||||
columns={colonnes}
|
||||
rowKey={(r) => r.codeFournisseur}
|
||||
pageSize={0}
|
||||
unite="fournisseurs"
|
||||
toolbar={
|
||||
<>
|
||||
<Segmented items={filtres} value={filtre} onChange={setFiltre} />
|
||||
<SearchInput
|
||||
value={recherche}
|
||||
onChange={setRecherche}
|
||||
placeholder="Code ou nom du fournisseur"
|
||||
aria-label="Rechercher un fournisseur"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
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 »."}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DESCRIPTION =
|
||||
"Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik).";
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SynchronisationClient } from "./client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Synchronisation nocturne — CollectFlow",
|
||||
};
|
||||
export const metadata: Metadata = { title: "Synchronisation" };
|
||||
|
||||
/**
|
||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||
|
||||
Reference in new issue
Block a user