diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
index 001ac14..5c2e17b 100644
--- a/src/app/(dashboard)/settings/page.tsx
+++ b/src/app/(dashboard)/settings/page.tsx
@@ -10,6 +10,7 @@ import { useEffect } from "react";
import { UserManagement } from "@/features/admin/components/user-management";
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
+import { GridWarmup } from "@/features/admin/components/grid-warmup";
interface OpenRouterModel { id: string; name: string; free: boolean; }
@@ -633,6 +634,13 @@ export default function SettingsPage() {
+
+
{/* Save Button — Floating/Sticky style at bottom */}
{
+ const session = await auth();
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if ((session.user as { role?: string } | undefined)?.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+async function runWarmup(j: WarmupJob, staleHours: number): Promise {
+ try {
+ const fournisseurs = await pgGetFournisseurs();
+ // Les fournisseurs déjà calculés récemment sont sautés : un second passage
+ // coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
+ const existing = await listGridSuppliers();
+ const freshLimit = Date.now() - staleHours * 3600_000;
+ const fresh = new Set(
+ existing
+ .filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
+ .map((s) => s.codeFournisseur),
+ );
+
+ j.total = fournisseurs.length;
+ console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
+
+ for (const f of fournisseurs) {
+ if (fresh.has(f.code)) {
+ j.skipped++;
+ continue;
+ }
+ j.currentFournisseur = `${f.code} — ${f.nom}`;
+ try {
+ await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
+ j.done++;
+ } catch (e) {
+ j.failed++;
+ const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
+ // On garde une trace des derniers échecs sans faire exploser la mémoire.
+ if (j.lastErrors.length < 10) j.lastErrors.push(msg);
+ console.error("[grid-warmup]", msg);
+ }
+ }
+
+ j.currentFournisseur = undefined;
+ j.status = "success";
+ j.finishedAt = new Date().toISOString();
+ console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
+ } catch (e) {
+ j.status = "error";
+ j.error = e instanceof Error ? e.message : String(e);
+ j.finishedAt = new Date().toISOString();
+ console.error("[grid-warmup] échec global:", j.error);
+ }
+}
+
+/** GET /api/admin/grid-warmup — avancement du préchauffage. */
+export async function GET() {
+ const denied = await requireAdmin();
+ if (denied) return denied;
+ return NextResponse.json({ success: true, ...publicJob(job) });
+}
+
+/**
+ * POST /api/admin/grid-warmup?staleHours=24
+ * Démarre le préchauffage en arrière-plan et répond immédiatement.
+ */
+export async function POST(req: NextRequest) {
+ const denied = await requireAdmin();
+ if (denied) return denied;
+
+ if (job?.status === "running") {
+ return NextResponse.json({ success: true, ...publicJob(job) });
+ }
+
+ const raw = Number(req.nextUrl.searchParams.get("staleHours"));
+ const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
+
+ job = {
+ status: "running",
+ total: 0,
+ done: 0,
+ skipped: 0,
+ failed: 0,
+ startedAt: new Date().toISOString(),
+ lastErrors: [],
+ };
+ const current = job;
+ console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
+
+ void runWarmup(current, staleHours).catch((e) => {
+ current.status = "error";
+ current.error = e instanceof Error ? e.message : String(e);
+ current.finishedAt = new Date().toISOString();
+ });
+
+ return NextResponse.json({ success: true, ...publicJob(job) });
+}
diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts
index c56afee..db01084 100644
--- a/src/app/api/v1/openapi.json/route.ts
+++ b/src/app/api/v1/openapi.json/route.ts
@@ -1,41 +1,48 @@
import { NextRequest } from "next/server";
-import { requireApiAuth } from "@/lib/api-auth";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
- * GET /api/v1/openapi.json
+ * GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
*
- * Spécification lisible par machine de l'API CollectFlow. Permet aux outils externes
- * — et à l'assistant IA — de découvrir les endpoints sans documentation manuscrite.
- * Authentifiée comme le reste de l'API : la spec décrit une surface interne.
+ * **Volontairement accessible sans authentification.** Le document ne décrit que la
+ * structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
+ * métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
+ * avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
+ * impossible.
+ *
+ * `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
+ * Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
*/
export async function GET(req: NextRequest) {
- const authCtx = await requireApiAuth(req);
- if (authCtx instanceof Response) return authCtx;
+ const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
+ const origin = configured || req.nextUrl.origin;
const paginationParams = [
- { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
- { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
+ { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
+ { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, description: "Nombre de lignes par page (500 maximum)." },
];
const sortParams = [
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
- { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" } },
+ { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
{
name: "fields",
in: "query",
schema: { type: "string" },
- description: "Champs de ProductRow à conserver, séparés par des virgules (allège fortement la réponse). `codein` est toujours inclus.",
+ description:
+ "Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
+ + "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
+ + "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
},
{
name: "enrich",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
- "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur (`codeGammeServeur`) au moment de l'appel. "
- + "0 : sert l'instantané brut, légèrement plus rapide.",
+ "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
+ + "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
},
];
@@ -45,22 +52,21 @@ export async function GET(req: NextRequest) {
title: "CollectFlow API",
version: "1.0.0",
description:
- "Lecture de la grille CollectFlow (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) "
- + "et recherche de produits.\n\n"
- + "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Toutes les réponses proviennent "
- + "de données déjà persistées : l'instantané de grille (`grid_rows`), rempli quand la Grille est "
- + "ouverte dans l'application, et le cache des métriques réseau (`qlik_network_metrics`). "
- + "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.\n\n"
- + "Deux informations sont relues à chaque appel car elles évoluent indépendamment du calcul de "
- + "la grille : les **métriques réseau Qlik** (`network`, `null` quand il n'y en a pas) et la "
- + "**gamme serveur non modifiée** (`codeGammeServeur`). Passer `enrich=0` pour s'en dispenser.",
+ "Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
+ + "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
+ + "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Les réponses proviennent de "
+ + "données déjà persistées : l'instantané de grille, écrit quand la Grille est calculée dans "
+ + "l'application, et le cache des métriques réseau. Un fournisseur dont l'instantané n'existe pas "
+ + "encore renvoie `202 not_ready`.\n\n"
+ + "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
+ + "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ + "(`codeGammeServeur`).",
},
- servers: [{ url: "/api/v1" }],
- security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }],
+ servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
+ security: [{ ApiKeyAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
- BearerAuth: { type: "http", scheme: "bearer" },
},
schemas: {
Error: {
@@ -74,7 +80,6 @@ export async function GET(req: NextRequest) {
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
},
message: { type: "string" },
- details: {},
},
},
},
@@ -88,49 +93,62 @@ export async function GET(req: NextRequest) {
totalPages: { type: "integer" },
},
},
- ProductRow: {
+ Fournisseur: {
type: "object",
- description: "Ligne de grille. Voir src/types/grid.ts pour la liste complète des champs.",
properties: {
- codein: { type: "string", description: "Identifiant interne FF Nancy." },
+ code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
+ nom: { type: ["string", "null"] },
+ hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
+ rowCount: { type: "integer" },
+ computedAt: { type: ["string", "null"], format: "date-time" },
+ },
+ },
+ NetworkMetrics: {
+ type: ["object", "null"],
+ description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
+ properties: {
+ caReseau: { type: "number" },
+ qteReseau: { type: "number" },
+ nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
+ caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
+ margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
+ qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
+ fetchedAt: { type: ["string", "null"], format: "date-time" },
+ },
+ },
+ Product: {
+ type: "object",
+ description: "Ligne de grille : un article chez un fournisseur.",
+ properties: {
+ codein: { type: "string", description: "Identifiant interne de l'article." },
codeFournisseur: { type: "string" },
nomFournisseur: { type: "string" },
- libelle1: { type: "string" },
+ libelle1: { type: "string", description: "Libellé de l'article." },
gtin: { type: "string" },
- codeCentrale: { type: "string", description: "Clé de jointure avec Qlik (format 10000XXXXXX)." },
- codeGamme: { type: ["string", "null"], description: "Gamme courante — peut être surchargée par un snapshot de session." },
+ reference: { type: "string" },
+ codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
codeGammeServeur: {
type: ["string", "null"],
- description: "Gamme **non modifiée**, telle qu'elle existe sur le serveur PostgreSQL. Relue à chaque appel (sauf `enrich=0`). `null` = aucune gamme en base.",
+ description:
+ "Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ + "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
+ + "C (performance), D (saisonnier/niche), Z (sortie).",
},
- codeGammeInit: { type: ["string", "null"], description: "Alias historique de `codeGammeServeur`, maintenu aligné." },
- network: {
- type: ["object", "null"],
- description: "Métriques réseau Qlik en cache, `null` si le produit n'en a pas.",
- properties: {
- caReseau: { type: "number" },
- qteReseau: { type: "number" },
- nbMagasinsReseau: { type: "integer" },
- caParMagasinReseau: { type: "number" },
- margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
- qteByMonth: { type: ["object", "null"], description: "Quantités par mois, clés YYYY-MM." },
- fetchedAt: { type: ["string", "null"], format: "date-time" },
- },
- },
- totalCa: { type: "number" },
+ codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
+ totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
tauxMarge: { type: "number" },
stockActuel: { type: "number" },
- sales12m: { type: "object", description: "Quantités vendues par mois, clés YYYYMM." },
- stock12m: { type: "object", description: "Stock fin de mois, clés YYYYMM." },
- sales12mByStore: { type: "object", description: "Ventes par magasin puis par mois." },
- caReseau: { type: "number" },
- qteReseau: { type: "number" },
- nbMagasinsReseau: { type: "integer" },
- caParMagasinReseau: { type: "number" },
- margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
- qteReseauByMonth: { type: ["object", "null"], description: "Quantités réseau par mois, clés YYYY-MM." },
+ pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
+ prixAchat: { type: "number" },
+ prixVente: { type: "number" },
+ derniereVente: { type: "string", format: "date" },
+ nbJoursDerniereVente: { type: "integer" },
+ sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
+ stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
+ sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
+ network: { $ref: "#/components/schemas/NetworkMetrics" },
},
},
},
@@ -138,73 +156,162 @@ export async function GET(req: NextRequest) {
paths: {
"/fournisseurs": {
get: {
- summary: "Liste des fournisseurs, avec la fraîcheur de leur instantané de grille",
+ operationId: "listerFournisseurs",
+ summary: "Lister les fournisseurs",
+ description:
+ "Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
+ + "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
parameters: [
- { name: "search", in: "query", schema: { type: "string" } },
- {
- name: "withData",
- in: "query",
- schema: { type: "string", enum: ["0", "1"] },
- description: "1 = uniquement les fournisseurs déjà présents dans l'instantané.",
- },
+ { name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
+ { name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
],
- responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
+ responses: {
+ "200": {
+ description: "Liste des fournisseurs",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
+ meta: { type: "object" },
+ },
+ },
+ },
+ },
+ },
+ "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ },
},
},
"/grid": {
get: {
- summary: "Lignes de grille d'un fournisseur",
- description: "Répond `202 not_ready` si le fournisseur n'a pas encore d'instantané.",
+ operationId: "listerProduitsFournisseur",
+ summary: "Lister les produits d'un fournisseur",
+ description:
+ "Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
+ + "réseau. Renvoie `202 not_ready` si les données de ce fournisseur n'ont pas encore été "
+ + "calculées — le signaler, plutôt que de conclure à l'absence de produits.",
parameters: [
- { name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
- { name: "gamme", in: "query", schema: { type: "string" } },
- { name: "code1", in: "query", schema: { type: "string" } },
- { name: "code2", in: "query", schema: { type: "string" } },
- { name: "code3", in: "query", schema: { type: "string" } },
- { name: "search", in: "query", schema: { type: "string" }, description: "Libellé, codein, GTIN, référence ou code centrale." },
+ { name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
+ { name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
+ { name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
+ { name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
+ { name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3." },
+ { name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
...sortParams,
...paginationParams,
],
responses: {
- "200": { description: "OK" },
- "202": { description: "Instantané pas encore calculé" },
- "400": { description: "Paramètres invalides" },
- "401": { description: "Non authentifié" },
+ "200": {
+ description: "Produits du fournisseur",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
+ pagination: { $ref: "#/components/schemas/Pagination" },
+ meta: { type: "object" },
+ },
+ },
+ },
+ },
+ },
+ "202": { description: "Données pas encore calculées pour ce fournisseur", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ "400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/products/search": {
get: {
- summary: "Recherche transversale de produits, tous fournisseurs confondus",
+ operationId: "rechercherProduits",
+ summary: "Rechercher des produits, tous fournisseurs confondus",
+ description:
+ "Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
+ + "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
parameters: [
- { name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 } },
- { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint la recherche à un fournisseur." },
- { name: "gamme", in: "query", schema: { type: "string" } },
+ { name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
+ { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
+ { name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
...sortParams,
...paginationParams,
],
- responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
+ responses: {
+ "200": {
+ description: "Produits correspondants",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
+ pagination: { $ref: "#/components/schemas/Pagination" },
+ meta: { type: "object" },
+ },
+ },
+ },
+ },
+ },
+ "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ },
},
},
"/products/{codein}": {
get: {
+ operationId: "obtenirProduit",
summary: "Fiche complète d'un produit",
+ description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
parameters: [
- { name: "codein", in: "path", required: true, schema: { type: "string" } },
- { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article multi-fournisseurs." },
+ { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
+ { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
],
- responses: { "200": { description: "OK" }, "404": { description: "Inconnu de l'instantané" } },
+ responses: {
+ "200": {
+ description: "Fiche produit",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
+ },
+ },
+ },
+ },
+ "404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ },
},
},
"/network/{codeCentrale}": {
get: {
- summary: "Métriques réseau Qlik en cache + courbe 12 mois",
- parameters: [{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" } }],
- responses: { "200": { description: "OK" }, "404": { description: "Absent du cache réseau" } },
+ operationId: "obtenirMetriquesReseau",
+ summary: "Métriques du réseau Qlik pour un produit",
+ description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
+ parameters: [
+ { name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
+ ],
+ responses: {
+ "200": {
+ description: "Métriques réseau",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
+ },
+ },
+ },
+ },
+ "404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
+ },
},
},
},
};
- return Response.json(spec);
+ // CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
+ return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
}
diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx
index 5febf0c..5cae73c 100644
--- a/src/features/admin/components/api-connection-info.tsx
+++ b/src/features/admin/components/api-connection-info.tsx
@@ -9,7 +9,7 @@
*/
import { useEffect, useState } from "react";
-import { Copy, Check, ExternalLink } from "lucide-react";
+import { Copy, Check, ExternalLink, Bot } from "lucide-react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -123,6 +123,37 @@ export function ApiConnectionInfo() {
{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}
+ {/* Branchement d'une IA externe (ChatGPT) */}
+
+
+
+ Connecter une IA externe (ChatGPT)
+
+
+ Créez une clé d'API dans la section ci-dessous et copiez-la.
+ Dans ChatGPT : Créer un GPT → onglet Configurer → Créer une action .
+
+ Cliquez sur Importer depuis une URL et collez l'adresse du schéma :
+ {`${base}/openapi.json`}
+
+ Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l'importer.
+ Votre application doit être joignable depuis Internet.
+
+
+
+ Dans Authentification , choisissez Clé d'API , type{" "}
+ Personnalisé , nom d'en-tête X-API-Key,
+ et collez votre clé.
+
+ Testez avec une question du type « cherche les produits tapis » — le GPT appellera rechercherProduits.
+
+
+ Si votre domaine public diffère de celui affiché ici, renseignez la variable
+ d'environnement COLLECTFLOW_PUBLIC_URL : elle fixe l'URL
+ déclarée dans le schéma.
+
+
+
{/* Comportement à connaître */}
diff --git a/src/features/admin/components/grid-warmup.tsx b/src/features/admin/components/grid-warmup.tsx
new file mode 100644
index 0000000..861982e
--- /dev/null
+++ b/src/features/admin/components/grid-warmup.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+/**
+ * CollectFlow — Préchauffage des données exposées par l'API.
+ *
+ * L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
+ * ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
+ * fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
+ * une bonne fois.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
+
+interface WarmupState {
+ status: "idle" | "running" | "success" | "error";
+ total: number;
+ done: number;
+ skipped: number;
+ failed: number;
+ currentFournisseur?: string;
+ finishedAt?: string;
+ error?: string;
+ lastErrors: string[];
+}
+
+export function GridWarmup() {
+ const [state, setState] = useState
(null);
+ const [error, setError] = useState(null);
+ const pollRef = useRef | null>(null);
+
+ const running = state?.status === "running";
+
+ const fetchState = useCallback(async () => {
+ try {
+ const res = await fetch("/api/admin/grid-warmup");
+ if (res.ok) setState(await res.json());
+ } catch {
+ // Réseau instable : on retentera au tick suivant.
+ }
+ }, []);
+
+ // Récupère l'état au montage : un préchauffage peut déjà tourner.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ useEffect(() => { fetchState(); }, [fetchState]);
+
+ // Suivi de l'avancement tant que le job tourne.
+ useEffect(() => {
+ if (!running) return;
+ let cancelled = false;
+ const tick = async () => {
+ await fetchState();
+ if (!cancelled) pollRef.current = setTimeout(tick, 2000);
+ };
+ pollRef.current = setTimeout(tick, 2000);
+ return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
+ }, [running, fetchState]);
+
+ const start = async () => {
+ setError(null);
+ try {
+ const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
+ const data = await res.json();
+ if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
+ setState(data);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ };
+
+ const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
+ const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
+
+ return (
+
+
+ L'API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
+ l'application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
+ Ce bouton calcule tous les fournisseurs ; ceux déjà à jour depuis moins de 24 h sont
+ sautés, il est donc sans risque de le relancer.
+
+
+
+
+ {running ? : }
+ {running ? "Préparation en cours…" : "Préparer les données pour l'API"}
+
+ {state && state.total > 0 && (
+
+ {traites} / {state.total} ({pct} %)
+
+ )}
+
+
+ {running && state && (
+
+
+ {state.currentFournisseur && (
+
+ En cours : {state.currentFournisseur}
+
+ )}
+
+ )}
+
+ {state?.status === "success" && (
+
+
+
+ Terminé : {state.done} fournisseur(s) calculé(s),{" "}
+ {state.skipped} déjà à jour
+ {state.failed > 0 && <>, {state.failed} en échec >}.
+
+
+ )}
+
+ {state && state.failed > 0 && state.lastErrors.length > 0 && (
+
+ Voir les échecs ({state.lastErrors.length} premiers)
+
+ {state.lastErrors.map((e, i) => {e} )}
+
+
+ )}
+
+ {(error || state?.error) && (
+
+
+
{error ?? state?.error}
+
+ )}
+
+ {state?.status === "idle" && (
+
+
+ Aucun préchauffage lancé depuis le démarrage de l'application.
+
+ )}
+
+ );
+}