mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 15:08:12 +02:00
fix(qlik): track async sync job status
This commit is contained in:
1 parent
e7bcba169c
commit
850852912f
2 files changed
+406
-57
No files matched your search
+186
-38
@@ -5,28 +5,107 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
|||||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||||
import { buildGridNetworkQlikDateFilter } from "@/lib/qlik-date-range";
|
import { buildGridNetworkQlikDateFilter } from "@/lib/qlik-date-range";
|
||||||
|
|
||||||
// Synchro potentiellement longue (extraction hypercube paginee).
|
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
|
||||||
|
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
|
||||||
|
// client tout de suite via le state job ci-dessous.
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
|
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
|
||||||
|
// besoin d'un process persistant pour conserver l'état de job en mémoire.
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** État d'un job de sync Qlik pour un fournisseur donné. */
|
||||||
|
type QlikSyncStatus = "idle" | "running" | "success" | "error";
|
||||||
|
|
||||||
|
interface QlikSyncJob {
|
||||||
|
jobId: string;
|
||||||
|
fournisseur: string;
|
||||||
|
status: QlikSyncStatus;
|
||||||
|
requested: number;
|
||||||
|
fetched: number;
|
||||||
|
upserted: number;
|
||||||
|
periode?: string;
|
||||||
|
dateDebut?: string;
|
||||||
|
dateFin?: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/qlik/sync?fournisseur=XXX
|
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
|
||||||
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
|
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
|
||||||
* et met a jour le cache local. Admin uniquement.
|
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
|
||||||
|
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
const jobs = new Map<string, QlikSyncJob>();
|
||||||
|
|
||||||
|
/** Construit un jobId court et lisible. */
|
||||||
|
function newJobId(): string {
|
||||||
|
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sérialise un job pour la réponse HTTP. */
|
||||||
|
function publicJob(job: QlikSyncJob) {
|
||||||
|
return {
|
||||||
|
jobId: job.jobId,
|
||||||
|
fournisseur: job.fournisseur,
|
||||||
|
status: job.status,
|
||||||
|
requested: job.requested,
|
||||||
|
fetched: job.fetched,
|
||||||
|
upserted: job.upserted,
|
||||||
|
periode: job.periode,
|
||||||
|
dateDebut: job.dateDebut,
|
||||||
|
dateFin: job.dateFin,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
finishedAt: job.finishedAt,
|
||||||
|
error: job.error,
|
||||||
|
message: job.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
|
||||||
|
function idleJob(fournisseur: string) {
|
||||||
|
return {
|
||||||
|
jobId: null,
|
||||||
|
fournisseur,
|
||||||
|
status: "idle" as const,
|
||||||
|
requested: 0,
|
||||||
|
fetched: 0,
|
||||||
|
upserted: 0,
|
||||||
|
periode: undefined,
|
||||||
|
dateDebut: undefined,
|
||||||
|
dateFin: undefined,
|
||||||
|
startedAt: undefined,
|
||||||
|
finishedAt: undefined,
|
||||||
|
error: undefined,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||||
|
*/
|
||||||
|
async function requireAdmin(): Promise<NextResponse | null> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
if (!fournisseur) {
|
|
||||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
|
||||||
|
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||||
|
*/
|
||||||
|
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Codes centraux des articles de ce fournisseur
|
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
|
||||||
const articles = await pgGetArticlesByFournisseur(fournisseur);
|
|
||||||
const codes = [
|
const codes = [
|
||||||
...new Set(
|
...new Set(
|
||||||
articles
|
articles
|
||||||
@@ -34,38 +113,107 @@ export async function POST(req: NextRequest) {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
console.log(`[api/qlik/sync] fournisseur=${fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`);
|
job.requested = codes.length;
|
||||||
|
console.log(
|
||||||
|
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`,
|
||||||
|
);
|
||||||
if (codes.length === 0) {
|
if (codes.length === 0) {
|
||||||
return NextResponse.json({
|
job.status = "success";
|
||||||
success: true,
|
job.message = "Aucun article avec code centrale pour ce fournisseur";
|
||||||
fournisseur,
|
job.finishedAt = new Date().toISOString();
|
||||||
fetched: 0,
|
return;
|
||||||
upserted: 0,
|
|
||||||
message: "Aucun article avec code centrale pour ce fournisseur",
|
|
||||||
fetchedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateFilter = buildGridNetworkQlikDateFilter();
|
const dateFilter = buildGridNetworkQlikDateFilter();
|
||||||
console.log(`[api/qlik/sync] → lancement extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`);
|
job.periode = dateFilter.label;
|
||||||
|
job.dateDebut = dateFilter.dateDebut;
|
||||||
|
job.dateFin = dateFilter.dateFin;
|
||||||
|
console.log(
|
||||||
|
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||||
|
);
|
||||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||||
console.log(`[api/qlik/sync] ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
job.fetched = metrics.size;
|
||||||
|
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
||||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||||
console.log(`[api/qlik/sync] ${count} lignes upsert dans le cache`);
|
job.upserted = count;
|
||||||
return NextResponse.json({
|
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
|
||||||
success: true,
|
job.status = "success";
|
||||||
fournisseur,
|
job.finishedAt = new Date().toISOString();
|
||||||
requested: codes.length,
|
|
||||||
fetched: metrics.size,
|
|
||||||
upserted: count,
|
|
||||||
periode: dateFilter.label,
|
|
||||||
dateDebut: dateFilter.dateDebut,
|
|
||||||
dateFin: dateFilter.dateFin,
|
|
||||||
fetchedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("[api/qlik/sync]", msg);
|
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||||
return NextResponse.json({ success: false, error: msg }, { status: 500 });
|
job.status = "error";
|
||||||
|
job.error = msg;
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/qlik/sync?fournisseur=XXX
|
||||||
|
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
|
||||||
|
* connu, renvoie un état "idle" (avec un message explicite). Permet au
|
||||||
|
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireAdmin();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||||
|
if (!fournisseur) {
|
||||||
|
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const job = jobs.get(fournisseur);
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/qlik/sync?fournisseur=XXX
|
||||||
|
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
|
||||||
|
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
|
||||||
|
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const denied = await requireAdmin();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||||
|
if (!fournisseur) {
|
||||||
|
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = jobs.get(fournisseur);
|
||||||
|
if (existing && existing.status === "running") {
|
||||||
|
// Un job tourne déjà pour ce fournisseur : on le réutilise.
|
||||||
|
return NextResponse.json({ success: true, ...publicJob(existing) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job: QlikSyncJob = {
|
||||||
|
jobId: newJobId(),
|
||||||
|
fournisseur,
|
||||||
|
status: "running",
|
||||||
|
requested: 0,
|
||||||
|
fetched: 0,
|
||||||
|
upserted: 0,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
jobs.set(fournisseur, job);
|
||||||
|
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
|
||||||
|
|
||||||
|
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
|
||||||
|
// renvoie tout de suite au client et il polle GET. Les erreurs sont
|
||||||
|
// capturées dans runJob et inscrites dans le job.
|
||||||
|
void runJob(job).catch((e) => {
|
||||||
|
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
|
||||||
|
// jamais qu'une exception non capturée crashe le process).
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
|
||||||
|
job.status = "error";
|
||||||
|
job.error = msg;
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
@@ -11,6 +11,23 @@ interface SyncQlikButtonProps {
|
|||||||
lastUpdate?: string | null;
|
lastUpdate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** État serveur renvoyé par GET/POST /api/qlik/sync */
|
||||||
|
interface SyncJobState {
|
||||||
|
jobId: string | null;
|
||||||
|
fournisseur: string;
|
||||||
|
status: "idle" | "running" | "success" | "error";
|
||||||
|
requested: number;
|
||||||
|
fetched: number;
|
||||||
|
upserted: number;
|
||||||
|
periode?: string;
|
||||||
|
dateDebut?: string;
|
||||||
|
dateFin?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso?: string | null): string {
|
function formatDate(iso?: string | null): string {
|
||||||
if (!iso) return "jamais";
|
if (!iso) return "jamais";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -21,34 +38,213 @@ function formatDate(iso?: string | null): string {
|
|||||||
/**
|
/**
|
||||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||||
|
*
|
||||||
|
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
|
||||||
|
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
|
||||||
|
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
|
||||||
|
* la page) et continuer à afficher le spinner correctement.
|
||||||
*/
|
*/
|
||||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||||
|
const trackingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const clearPollTimer = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearTimeout(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le label visible à droite du bouton selon l'état courant.
|
||||||
|
*/
|
||||||
|
const applyJobToUi = useCallback((j: SyncJobState | null) => {
|
||||||
|
if (!j) return;
|
||||||
|
if (j.status === "running") {
|
||||||
|
setStatus("running");
|
||||||
|
// Texte informatif pendant l'extraction
|
||||||
|
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
|
||||||
|
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||||
|
} else if (j.status === "success") {
|
||||||
|
setStatus("success");
|
||||||
|
if (j.message) setMessage(j.message);
|
||||||
|
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||||
|
} else if (j.status === "error") {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage(j.error || "Erreur sync Qlik");
|
||||||
|
} else {
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
|
||||||
|
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
|
||||||
|
*/
|
||||||
|
const refreshGrid = useCallback(() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("_refresh", String(Date.now()));
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
router.refresh();
|
||||||
|
}, [pathname, router, searchParams]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
|
||||||
|
* passe en success/error, ou après un timeout de sécurité.
|
||||||
|
*/
|
||||||
|
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!codeFournisseur) return false;
|
||||||
|
if (!trackingRef.current) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||||
|
{ method: "GET", cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
// En cas d'erreur HTTP transitoire on retente au tour suivant.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const data: SyncJobState = await res.json();
|
||||||
|
applyJobToUi(data);
|
||||||
|
|
||||||
|
if (data.status === "running") {
|
||||||
|
// Continue à poller
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (data.status === "success" || data.status === "error") {
|
||||||
|
// Terminé : on rafraîchit la grille, on coupe le polling.
|
||||||
|
trackingRef.current = false;
|
||||||
|
if (data.status === "success") {
|
||||||
|
refreshGrid();
|
||||||
|
}
|
||||||
|
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
|
||||||
|
setTimeout(() => {
|
||||||
|
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||||
|
}, 4000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// idle : rien à faire
|
||||||
|
trackingRef.current = false;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
// Erreur réseau transitoire, on laisse le polling réessayer.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, [applyJobToUi, codeFournisseur, refreshGrid]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
|
||||||
|
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
|
||||||
|
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
|
||||||
|
*/
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
clearPollTimer();
|
||||||
|
const tick = async () => {
|
||||||
|
const keepGoing = await pollOnce();
|
||||||
|
if (keepGoing && trackingRef.current) {
|
||||||
|
pollTimerRef.current = setTimeout(tick, 3000);
|
||||||
|
} else {
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Premier appel quasi immédiat
|
||||||
|
pollTimerRef.current = setTimeout(tick, 200);
|
||||||
|
}, [clearPollTimer, pollOnce]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
|
||||||
|
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
|
||||||
|
* oui, on reprend l'état et on relance le polling.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!codeFournisseur) return;
|
||||||
|
let cancelled = false;
|
||||||
|
trackingRef.current = false;
|
||||||
|
clearPollTimer();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||||
|
{ method: "GET", cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data: SyncJobState = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (data.status === "running" && data.jobId) {
|
||||||
|
trackingRef.current = true;
|
||||||
|
applyJobToUi(data);
|
||||||
|
startPolling();
|
||||||
|
} else if (data.status === "success" || data.status === "error") {
|
||||||
|
// Job terminé en amont (avant le mount) : on l'affiche brièvement
|
||||||
|
applyJobToUi(data);
|
||||||
|
setTimeout(() => {
|
||||||
|
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore : on n'empêche pas l'UI d'afficher le bouton */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
trackingRef.current = false;
|
||||||
|
clearPollTimer();
|
||||||
|
};
|
||||||
|
// On veut interroger une seule fois au mount (et quand le fournisseur change).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [codeFournisseur, clearPollTimer]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup : annule un timer de polling éventuel au démontage.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearPollTimer();
|
||||||
|
};
|
||||||
|
}, [clearPollTimer]);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
if (!codeFournisseur) {
|
if (!codeFournisseur) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setMessage("Aucun fournisseur sélectionné");
|
setMessage("Aucun fournisseur sélectionné");
|
||||||
|
setTimeout(() => setStatus("idle"), 6000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("loading");
|
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||||
setMessage("");
|
if (status === "running") return;
|
||||||
|
|
||||||
|
setStatus("running");
|
||||||
|
setMessage("Démarrage de l'extraction Qlik…");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
|
const res = await fetch(
|
||||||
const data = await res.json();
|
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||||
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
|
{ method: "POST" },
|
||||||
setStatus("success");
|
);
|
||||||
setMessage(`${data.upserted} produits réseau synchronisés`);
|
const data: SyncJobState & { success?: boolean } = await res.json();
|
||||||
// Bump _refresh → grid-client refetch /api/grid/rows avec refresh=1 (forceRefresh,
|
if (!res.ok || (data as { success?: boolean }).success === false) {
|
||||||
// bypass cache 10 min de getProductRows) pour afficher les nouvelles métriques réseau.
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
}
|
||||||
params.set("_refresh", String(Date.now()));
|
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
|
||||||
router.replace(`${pathname}?${params.toString()}`);
|
// on reprend juste le suivi.
|
||||||
router.refresh();
|
if (data.status === "running" && data.jobId) {
|
||||||
|
trackingRef.current = true;
|
||||||
|
applyJobToUi(data);
|
||||||
|
startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Status terminal direct (cas rare : 0 codes → success immédiat)
|
||||||
|
applyJobToUi(data);
|
||||||
|
if (data.status === "success") {
|
||||||
|
refreshGrid();
|
||||||
|
}
|
||||||
setTimeout(() => setStatus("idle"), 4000);
|
setTimeout(() => setStatus("idle"), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -62,16 +258,21 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
|||||||
<span
|
<span
|
||||||
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
|
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
|
||||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||||
|
title={message || undefined}
|
||||||
>
|
>
|
||||||
{status === "error" ? message : `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
{status === "error"
|
||||||
|
? message
|
||||||
|
: status === "running"
|
||||||
|
? (message || `Extraction Qlik…`)
|
||||||
|
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={status === "loading"}
|
disabled={status === "running"}
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
|
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
|
||||||
>
|
>
|
||||||
{status === "loading" ? (
|
{status === "running" ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
) : status === "success" ? (
|
) : status === "success" ? (
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
@@ -80,7 +281,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
Sync Qlik
|
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user