merge: intègre main — main prioritaire, abandon de la page en doublon

main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-06 11:13:44 +00:00
commit 3fa92e891b
50 files changed
+8488 -2017

No files matched your search

@@ -0,0 +1,148 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react";
interface LogEntry {
id: string;
octets: number;
modifieLe: string;
enCours: boolean;
}
function formatTaille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString("fr-FR", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */
async function recupererLogs(): Promise<LogEntry[]> {
const res = await fetch("/api/logs", { cache: "no-store" });
if (!res.ok) {
throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`);
}
const data = await res.json();
return Array.isArray(data.logs) ? data.logs : [];
}
/**
* Journaux serveur des extractions Qlik — consultation et téléchargement.
*
* Une extraction produit plusieurs milliers de lignes de diagnostic. Un
* terminal les tronque et n'en laisse que la fin, alors que l'information
* décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve
* en tête. Le journal complet est donc capturé côté serveur et récupérable ici
* en un fichier.
*/
export function ServerLogs() {
const [logs, setLogs] = useState<LogEntry[]>([]);
// « chargement » dès le départ : le premier appel part au montage, et aucun
// setState ne doit être fait de façon synchrone dans un effet.
const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement");
const [erreur, setErreur] = useState<string>("");
/** Applique un résultat (succès ou échec) à l'état local. */
const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => {
if (entrees) {
setLogs(entrees);
setErreur("");
setEtat("idle");
} else {
setErreur(message);
setEtat("erreur");
}
}, []);
// Chargement initial. L'état n'est touché que depuis les callbacks de la
// promesse, jamais dans le corps de l'effet.
useEffect(() => {
recupererLogs()
.then((entrees) => appliquer(entrees, ""))
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
}, [appliquer]);
/** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */
const recharger = useCallback(() => {
setEtat("chargement");
recupererLogs()
.then((entrees) => appliquer(entrees, ""))
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
}, [appliquer]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] text-[var(--text-secondary)]">
Journal complet de chaque extraction Qlik (le plus récent en premier).
Conservés le temps de la vie du serveur, 20 au maximum.
</p>
<button
onClick={recharger}
disabled={etat === "chargement"}
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
>
{etat === "chargement"
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <RefreshCw className="w-3.5 h-3.5" />}
Actualiser
</button>
</div>
{etat === "erreur" && (
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{erreur}</span>
</div>
)}
{etat !== "erreur" && logs.length === 0 && (
<p className="text-[12px] text-[var(--text-muted)]">
Aucun journal pour l&apos;instant — lancez une synchronisation Qlik depuis la Grille.
</p>
)}
{logs.length > 0 && (
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
{logs.map((log) => (
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
{log.id}
</span>
{log.enCours && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
en cours
</span>
)}
</div>
<span className="text-[11px] text-[var(--text-muted)]">
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
</span>
</div>
<a
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
download
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
>
<Download className="w-3.5 h-3.5" />
Télécharger
</a>
</li>
))}
</ul>
)}
</div>
);
}