From b373dabeaa0586f06bd9e786757278f5db9e7fef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:27:07 +0000 Subject: [PATCH] =?UTF-8?q?Gammes=20de=20l'API=20=C3=A0=20valider=20dans?= =?UTF-8?q?=20la=20Grille,=20analyses=20pleine=20largeur,=20r=C3=A9f=C3=A9?= =?UTF-8?q?rence=20dans=20Meilleures=20ventes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API /api/v1 : PUT /products/:codein/gamme et POST /gammes déposent une proposition (table gammes_a_valider) au lieu d'enregistrer. La Grille la charge comme modification non enregistrée (badge « API ») : Enregistrer la valide, Annuler la rejette. Lecture : champ gammeAValider, statut a_valider dans la réponse ; OpenAPI mise à jour. - Meilleures ventes, Ventes par mois, Publicités : plus de largeur max centrée, les pages occupent toute la largeur. - Meilleures ventes : colonne Référence (référence fournisseur), recherche et export Excel compris. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa --- scripts/db-init.js | 14 +++ src/app/(dashboard)/analytics/page.tsx | 2 +- src/app/(dashboard)/hit-parade/client.tsx | 19 +++- src/app/(dashboard)/hit-parade/page.tsx | 5 +- src/app/(dashboard)/publicites/page.tsx | 2 +- src/app/api/v1/gammes/route.ts | 5 +- src/app/api/v1/openapi.json/route.ts | 33 +++--- .../api/v1/products/[codein]/gamme/route.ts | 5 +- src/db/schema.ts | 25 ++++- .../admin/components/api-connection-info.tsx | 4 +- .../grid/api/gammes-a-valider-actions.ts | 37 +++++++ src/features/grid/api/save-draft-changes.ts | 9 +- .../grid/components/floating-summary-bar.tsx | 49 +++++++-- src/features/grid/components/grid-client.tsx | 21 ++++ src/features/grid/components/heatmap-grid.tsx | 11 ++ src/features/grid/hooks/use-save-drafts.ts | 20 +++- src/features/grid/store/use-grid-store.ts | 45 ++++++++ src/lib/api-enrich.ts | 13 ++- src/lib/api-gammes.ts | 57 +++++----- src/lib/gammes-a-valider.ts | 104 ++++++++++++++++++ src/lib/pg-ff-client.ts | 6 +- 21 files changed, 413 insertions(+), 73 deletions(-) create mode 100644 src/features/grid/api/gammes-a-valider-actions.ts create mode 100644 src/lib/gammes-a-valider.ts diff --git a/scripts/db-init.js b/scripts/db-init.js index 912a925..c4290e2 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -212,6 +212,20 @@ async function main() { `); console.log("[DB Init] Table sync_fournisseurs is verified/created."); + // Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille. + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "gammes_a_valider" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "gamme" varchar(20) NOT NULL, + "gamme_precedente" varchar(20), + "auteur" varchar(100), + "created_at" timestamp DEFAULT now() NOT NULL, + PRIMARY KEY ("code_fournisseur", "codein") + ); + `); + console.log("[DB Init] Table gammes_a_valider is verified/created."); + await tempPool.end(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 39a4cdf..3f984df 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -141,7 +141,7 @@ export default async function AnalyticsPage(props: { } return ( -
+
[] = [ footer: () => "Total", className: "whitespace-nowrap", }, + { + id: "reference", + header: "Référence", + hint: "Référence fournisseur de l'article", + sortValue: r => r.reference, + cell: r => (r.reference ? {r.reference} : vide), + className: "whitespace-nowrap", + }, { id: "libelle", header: "Désignation", @@ -170,7 +178,7 @@ const FILTRES: DataFilter[] = [ { id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" }, ]; -const recherche = (r: Ligne) => [r.codein, r.libelle]; +const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle]; interface Props { dateDebut: string; @@ -204,21 +212,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { feuille: "Meilleures ventes", fichier: `meilleures-ventes_${dateDebut}_${dateFin}`, entetes: [ - "Code", "Désignation", "Fournisseur", "Nomenclature", + "Code", "Référence", "Désignation", "Fournisseur", "Nomenclature", ...GROUPES.flatMap(g => [ `Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`, ]), ], - largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], + largeurs: [12, 18, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], lignes: rows.map(r => [ r.codein, + r.reference, r.libelle.trim(), r.fournisseur, r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "", ...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]), ]), totaux: [ - `TOTAL — ${rows.length} articles`, "", "", "", + `TOTAL — ${rows.length} articles`, "", "", "", "", ...GROUPES.flatMap(g => { const ca = somme(rows, g.ca); return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)]; @@ -266,7 +275,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { columns={COLONNES} rowKey={r => r.codein} searchIn={recherche} - searchPlaceholder="Code ou désignation…" + searchPlaceholder="Code, référence ou désignation…" filters={FILTRES} pageSize={100} unite="produits" diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx index be6ff04..0aa1ade 100644 --- a/src/app/(dashboard)/hit-parade/page.tsx +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -13,6 +13,8 @@ export const metadata: Metadata = { title: "Meilleures ventes" }; export interface HitParadePivotRow { codein: string; libelle: string; + /** Référence fournisseur de l'article. */ + reference: string; fournisseur: string; nomenclature_code: string; nomenclature: string; @@ -38,6 +40,7 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] { map.set(row.codein, { codein: row.codein, libelle: row.libelle, + reference: row.reference ?? "", fournisseur: row.fournisseur, nomenclature_code: row.nomenclature_code, nomenclature: row.nomenclature, @@ -100,7 +103,7 @@ export default async function HitParadePage(props: { } return ( -
+
+
r.modifie).length, + aValider: res.resultats.filter((r) => r.statut === "a_valider").length, enregistreLe: new Date().toISOString(), }, }); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index cd99026..3b2588d 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -72,9 +72,11 @@ export async function GET(req: NextRequest) { + "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée " + "(`codeGammeServeur`).\n\n" + "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) " - + "affectent ou changent une gamme exactement comme la Grille : la nouvelle valeur apparaît " - + "aussitôt dans `codeGamme` et dans l'application, tandis que `codeGammeServeur` reste la gamme " - + "en base FF jusqu'à l'import des gammes modifiées.", + + "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille " + + "comme une modification non enregistrée, pour voir quels produits changent de gamme, et " + + "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, " + + "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, " + + "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.", }, servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }], security: [{ ApiKeyAuth: [] }], @@ -151,7 +153,8 @@ export async function GET(req: NextRequest) { "Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour " + "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".", }, - codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." }, + codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." }, + gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." }, totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." }, totalQuantite: { type: "number" }, totalMarge: { type: "number" }, @@ -177,10 +180,11 @@ export async function GET(req: NextRequest) { type: "object", properties: { codein: { type: "string" }, - gamme: { type: "string", enum: codesGamme, description: "Gamme désormais affectée." }, - gammePrecedente: { type: ["string", "null"], description: "Gamme courante avant l'appel." }, + gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." }, + gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." }, codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." }, - modifie: { type: "boolean", description: "false = l'article avait déjà cette gamme, rien n'a été écrit." }, + modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." }, + statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." }, }, }, Trend: { @@ -390,9 +394,10 @@ export async function GET(req: NextRequest) { "/products/{codein}/gamme": { put: { operationId: "changerGammeProduit", - summary: "Affecter ou changer la gamme d'un produit", + summary: "Proposer la gamme d'un produit (à valider)", description: - "Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". " + "Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît " + + "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". " + "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs " + "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. " + "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", @@ -417,7 +422,7 @@ export async function GET(req: NextRequest) { }, responses: { "200": { - description: "Gamme enregistrée", + description: "Gamme proposée, à valider", content: { "application/json": { schema: { @@ -444,10 +449,10 @@ export async function GET(req: NextRequest) { "/gammes": { post: { operationId: "changerGammes", - summary: "Affecter ou changer la gamme de plusieurs produits", + summary: "Proposer la gamme de plusieurs produits (à valider)", description: - "Enregistre en un appel les gammes de plusieurs articles d'un même fournisseur. " - + "Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est enregistré " + "Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider " + + "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé " + "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. " + "Valeurs : " + legendeGammes + ".", requestBody: { @@ -480,7 +485,7 @@ export async function GET(req: NextRequest) { }, responses: { "200": { - description: "Gammes enregistrées", + description: "Gammes proposées, à valider", content: { "application/json": { schema: { diff --git a/src/app/api/v1/products/[codein]/gamme/route.ts b/src/app/api/v1/products/[codein]/gamme/route.ts index fa22a2f..926ffc3 100644 --- a/src/app/api/v1/products/[codein]/gamme/route.ts +++ b/src/app/api/v1/products/[codein]/gamme/route.ts @@ -13,8 +13,9 @@ export const maxDuration = 300; /** * PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" } * - * Affecte une gamme à un article, ou la change. Même enregistrement que la Grille : - * la nouvelle gamme est visible immédiatement dans l'application et dans `codeGamme`. + * Propose une gamme pour un article. La proposition reste **à valider** : elle + * apparaît dans la Grille comme une modification non enregistrée, et `codeGamme` + * ne change qu'une fois validée (« Enregistrer » dans la Grille). * * `fournisseur` n'est requis que si l'article est référencé chez plusieurs * fournisseurs, ou s'il n'apparaît pas encore dans l'instantané. diff --git a/src/db/schema.ts b/src/db/schema.ts index dec4ba0..a480879 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core"; +import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core"; export const ventesProduits = pgTable("ventes_produits", { id: serial("id").primaryKey(), @@ -283,3 +283,26 @@ export const aiSupplierContext = pgTable("ai_supplier_context", { /** When it was last updated */ updatedAt: timestamp("updated_at").defaultNow(), }); + +/** + * Gammes proposées par l'API `/api/v1`, en attente de validation. + * + * L'API n'enregistre plus directement : chaque proposition attend ici qu'un + * utilisateur l'examine dans la Grille, où elle apparaît comme une modification + * non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler + * les modifications » la rejette ; dans les deux cas la ligne est supprimée. + * Une seule proposition par article et fournisseur : la dernière l'emporte. + */ +export const gammesAValider = pgTable("gammes_a_valider", { + codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), + codein: varchar("codein", { length: 20 }).notNull(), + /** Gamme proposée. */ + gamme: varchar("gamme", { length: 20 }).notNull(), + /** Gamme enregistrée au moment de la proposition. */ + gammePrecedente: varchar("gamme_precedente", { length: 20 }), + /** Nom de la clé d'API à l'origine de la proposition. */ + auteur: varchar("auteur", { length: 100 }), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => [ + primaryKey({ columns: [table.codeFournisseur, table.codein] }), +]); diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index 175675f..7aee6bd 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -79,8 +79,8 @@ const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [ { method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nombre d'articles et chiffre d'affaires" }, { method: "GET", path: "/products/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" }, { method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" }, - { method: "PUT", path: "/products/{codein}/gamme", desc: "Affecter ou changer la gamme d'un produit — corps : { \"gamme\": \"A\" }" }, - { method: "POST", path: "/gammes", desc: "Affecter ou changer la gamme de plusieurs produits d'un fournisseur en un appel" }, + { method: "PUT", path: "/products/{codein}/gamme", desc: "Proposer la gamme d'un produit, à valider dans la Révision d'assortiment — corps : { \"gamme\": \"A\" }" }, + { method: "POST", path: "/gammes", desc: "Proposer la gamme de plusieurs produits d'un fournisseur en un appel, à valider dans la Révision d'assortiment" }, { method: "GET", path: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" }, { method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" }, ]; diff --git a/src/features/grid/api/gammes-a-valider-actions.ts b/src/features/grid/api/gammes-a-valider-actions.ts new file mode 100644 index 0000000..0a7a8f9 --- /dev/null +++ b/src/features/grid/api/gammes-a-valider-actions.ts @@ -0,0 +1,37 @@ +"use server"; + +import { z } from "zod"; +import { verifierSession } from "@/lib/authz"; +import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider"; + +/** Gammes proposées par l'API pour un fournisseur, en attente de validation. */ +export async function getGammesAValider( + codeFournisseur: string, +): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> { + const acces = await verifierSession(); + if (!acces.ok) return { success: false, propositions: [], error: acces.message }; + try { + return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) }; + } catch (err) { + return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" }; + } +} + +const RejetSchema = z.object({ + codeFournisseur: z.string().min(1), + codeins: z.array(z.string()), +}); + +/** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */ +export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> { + const acces = await verifierSession(); + if (!acces.ok) return { success: false, error: acces.message }; + const parsed = RejetSchema.safeParse(raw); + if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message }; + try { + await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; + } +} diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts index ee70b34..1c432cd 100644 --- a/src/features/grid/api/save-draft-changes.ts +++ b/src/features/grid/api/save-draft-changes.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { verifierSession } from "@/lib/authz"; import { enregistrerGammes } from "./enregistrer-gammes"; +import { supprimerGammesAValider } from "@/lib/gammes-a-valider"; const SaveDraftsSchema = z.object({ codeFournisseur: z.string(), @@ -15,6 +16,11 @@ const SaveDraftsSchema = z.object({ codeGamme: z.string(), }) ), + /** + * Propositions de l'API examinées dans la Grille : validées (présentes dans + * `changes`) ou écartées. Elles quittent la file « à valider ». + */ + gammesAValiderTraitees: z.array(z.string()).optional(), }); export async function saveDraftChanges( @@ -28,7 +34,7 @@ export async function saveDraftChanges( return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message }; } - const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data; + const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data; const rawUserId = acces.utilisateur.id; const userId = rawUserId ? parseInt(rawUserId, 10) : null; @@ -36,6 +42,7 @@ export async function saveDraftChanges( try { const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId }); + await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees); return { success: true, saved }; } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index aa3b854..a1426e5 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -8,6 +8,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; +import { rejeterGammesAValider } from "@/features/grid/api/gammes-a-valider-actions"; import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { DropdownMenu, @@ -62,7 +63,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean const [isPending, startTransition] = useTransition(); const [enCours, setEnCours] = useState(null); - const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL"); + const { save, hasDrafts, count, nbAValider } = useSaveDrafts(activeMagasin || "TOTAL"); const supplierCode = filterFournisseur || rows[0]?.codeFournisseur; const lastQlikUpdate = useMemo(() => { @@ -77,7 +78,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean startTransition(async () => { try { const result = await save(); - if (result.success) { + if (result.success && result.saved === 0) { + toast.info("Propositions de l'API écartées."); + } else if (result.success) { toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`); } else { toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`); @@ -89,16 +92,36 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean }; const handleReset = async () => { + const pluriel = count > 1 ? "s" : ""; + const message = count > 0 + ? `Les ${count} gamme${pluriel} modifiée${pluriel} et non enregistrée${pluriel} reviendront à leur valeur précédente.` + : ""; const ok = await confirmer({ titre: "Annuler les modifications ?", - message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`, + message: nbAValider > 0 + ? `${message} ${nbAValider > 1 ? `Les ${nbAValider} gammes proposées` : "La gamme proposée"} par l'API ${nbAValider > 1 ? "seront rejetées" : "sera rejetée"}.`.trim() + : message, libelleConfirmer: "Annuler les modifications", danger: true, }); - if (ok) { - resetDrafts(); - toast.info("Modifications annulées."); + if (!ok) return; + if (nbAValider > 0) { + const { gammesAValider, rowsByCodein, retirerGammesAValider } = useGridStore.getState(); + const codeins = Object.keys(gammesAValider).filter((c) => rowsByCodein[c]); + const codeFournisseur = filterFournisseur || rows[0]?.codeFournisseur; + try { + const res = codeFournisseur + ? await rejeterGammesAValider({ codeFournisseur, codeins }) + : { success: false, error: "fournisseur inconnu" }; + if (!res.success) throw new Error(res.error); + retirerGammesAValider(codeins); + } catch (err) { + toast.erreur(`Les propositions de l'API n'ont pas pu être rejetées : ${messageErreur(err)}`); + return; + } } + resetDrafts(); + toast.info("Modifications annulées."); }; /** @@ -256,6 +279,14 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean + {nbAValider > 0 && ( + + {nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l'API à valider + + )} {hasDrafts && (
diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 3a02738..34bf1ca 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -7,6 +7,7 @@ import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolba import { GridFilterBar } from "@/features/grid/components/grid-filter-bar"; import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; import { useStorePatch } from "@/features/grid/hooks/use-store-patch"; +import { getGammesAValider } from "@/features/grid/api/gammes-a-valider-actions"; import type { ProductRow } from "@/types/grid"; import { AlertCircle, Loader2, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -231,6 +232,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga return () => controller.abort(); }, [codeFournisseur, refreshRequest, setRows, isMounted]); + // Gammes proposées par l'API : relues une fois les lignes du fournisseur + // chargées (ou retrouvées en mémoire), puis affichées comme modifications + // non enregistrées, à valider. + const lignesChargeesLe = useGridStore((s) => (s.rowsMeta?.key === rowsKeyFor(codeFournisseur) ? s.rowsMeta.loadedAt : null)); + useEffect(() => { + if (!isMounted || lignesChargeesLe === null) return; + let annule = false; + getGammesAValider(codeFournisseur) + .then((res) => { + if (annule) return; + if (!res.success) { + console.error("[grid] gammes à valider KO:", res.error); + return; + } + useGridStore.getState().chargerGammesAValider(res.propositions); + }) + .catch((e) => console.error("[grid] gammes à valider KO:", e)); + return () => { annule = true; }; + }, [codeFournisseur, lignesChargeesLe, isMounted]); + // Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée. useStorePatch(codeFournisseur, isMounted); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e76b6cc..1f9c071 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -244,6 +244,9 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo const isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined); const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme); const setDraftGamme = useGridStore((s) => s.setDraftGamme); + // Proposition de l'API affichée telle quelle : à valider. + const proposition = useGridStore((s) => s.gammesAValider[codein]); + const proposeeParApi = isDraft && proposition !== undefined && proposition.gamme === effectiveGamme; const isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null; const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme; @@ -267,6 +270,14 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo isDraft={isDraft} onChange={(g: GammeCode) => setDraftGamme(codein, g)} /> + {proposeeParApi && ( +
+ API +
+ )} {isModified && !isDraft && (
diff --git a/src/features/grid/hooks/use-save-drafts.ts b/src/features/grid/hooks/use-save-drafts.ts index 29725e0..5cdc49f 100644 --- a/src/features/grid/hooks/use-save-drafts.ts +++ b/src/features/grid/hooks/use-save-drafts.ts @@ -23,9 +23,15 @@ export function useSaveDrafts(magasin: string) { for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++; return n; }); + /** Propositions de l'API en attente sur les lignes chargées. */ + const nbAValider = useGridStore((s) => { + let n = 0; + for (const codein in s.gammesAValider) if (s.rowsByCodein[codein]) n++; + return n; + }); const save = useCallback(async () => { - const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState(); + const { draftChanges, rowsByCodein, rows, gammesAValider, applyDraftsToRows, clearDrafts, retirerGammesAValider } = useGridStore.getState(); const activeDrafts = modificationsActives(draftChanges, rowsByCodein); // Index par code article : `rows.find` dans la boucle coûtait // (modifications × lignes) comparaisons sur un gros fournisseur. @@ -37,15 +43,18 @@ export function useSaveDrafts(magasin: string) { codeGamme: codeGamme as GammeCode, }; }); + // Propositions de l'API examinées : enregistrées si elles sont restées en + // modification, écartées sinon. Dans les deux cas, elles sont traitées. + const gammesAValiderTraitees = Object.keys(gammesAValider).filter((codein) => rowsByCodein[codein]); - if (changes.length === 0) return { success: true, saved: 0 }; + if (changes.length === 0 && gammesAValiderTraitees.length === 0) return { success: true, saved: 0 }; // Fournisseur déduit de la première ligne modifiée - const firstRow = rowsByCodein[changes[0].codein]; + const firstRow = rowsByCodein[changes[0]?.codein ?? gammesAValiderTraitees[0]] ?? rows[0]; const codeFournisseur = firstRow?.codeFournisseur ?? ""; const nomFournisseur = firstRow?.nomFournisseur ?? ""; - const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes }); + const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees }); if (result.success) { // Report des valeurs enregistrées sur row.codeGamme AVANT de vider les @@ -53,9 +62,10 @@ export function useSaveDrafts(magasin: string) { // codeGammeInit) reste juste. applyDraftsToRows(activeDrafts); clearDrafts(Object.keys(activeDrafts)); + retirerGammesAValider(gammesAValiderTraitees); } return result; }, [magasin]); - return { save, hasDrafts: count > 0, count }; + return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider }; } diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index ebe4eee..2579754 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -15,6 +15,14 @@ export interface RowsMeta { patchedStores: string[]; } +/** Gamme proposée par l'API, en attente de validation (cf. lib/gammes-a-valider). */ +export interface GammeProposee { + gamme: string; + auteur: string | null; + /** Date ISO de la proposition. */ + proposeeLe: string; +} + /** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */ export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`; @@ -25,6 +33,20 @@ interface GridState { rowsByCodein: Record; /** Draft edits: codein → new GammeCode */ draftChanges: Record; + /** + * Propositions de l'API en attente pour le fournisseur affiché (codein → + * proposition). Non persisté : relu sur le serveur à chaque chargement. + */ + gammesAValider: Record; + /** + * Range les propositions de l'API et les reporte en modifications non + * enregistrées — sauf là où l'utilisateur a déjà sa propre modification, et + * sauf celles déjà connues (une proposition écartée à la main ne revient pas + * à chaque retour sur la Grille). + */ + chargerGammesAValider: (propositions: ReadonlyArray<{ codein: string } & GammeProposee>) => void; + /** Retire des propositions traitées (validées ou rejetées). */ + retirerGammesAValider: (codeins: string[]) => void; filters: GridFilters; summary: GridSummary; displayDensity: "compact" | "normal" | "comfortable"; @@ -141,6 +163,7 @@ export const useGridStore = create()( rowsByCodein: {}, activeMagasin: "TOTAL", draftChanges: {}, + gammesAValider: {}, filters: { magasin: null, codeFournisseur: null, @@ -211,6 +234,28 @@ export const useGridStore = create()( }, 80); }, + chargerGammesAValider: (propositions) => { + const { rowsByCodein, draftChanges, gammesAValider: connues } = get(); + const gammesAValider: Record = {}; + const nouvelles: Record = {}; + for (const p of propositions) { + const row = rowsByCodein[p.codein]; + if (!row) continue; + gammesAValider[p.codein] = { gamme: p.gamme, auteur: p.auteur, proposeeLe: p.proposeeLe }; + const dejaConnue = connues[p.codein]?.proposeeLe === p.proposeeLe; + if (!dejaConnue && draftChanges[p.codein] === undefined && p.gamme !== row.codeGamme) { + nouvelles[p.codein] = p.gamme as GammeCode; + } + } + set({ gammesAValider }); + if (Object.keys(nouvelles).length > 0) get().batchSetDraftGamme(nouvelles); + }, + retirerGammesAValider: (codeins) => { + const gammesAValider = { ...get().gammesAValider }; + codeins.forEach((id) => delete gammesAValider[id]); + set({ gammesAValider }); + }, + resetDrafts: () => { set({ draftChanges: {} }); if (summaryTimer) clearTimeout(summaryTimer); diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts index b99e52e..143ca9d 100644 --- a/src/lib/api-enrich.ts +++ b/src/lib/api-enrich.ts @@ -22,6 +22,7 @@ import "server-only"; import type { ProductRow } from "@/types/grid"; import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; import { pgGetGammesByCodeins } from "@/lib/pg-ff-client"; +import { gammesAValiderParArticle } from "@/lib/gammes-a-valider"; // Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans // l'application, pas une seconde implémentation qui divergerait. import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend"; @@ -51,6 +52,11 @@ export interface EnrichedProductRow extends ProductRow { * `null` si l'article n'a aucune gamme en base. */ codeGammeServeur: string | null; + /** + * Gamme proposée par l'API et pas encore validée dans la Grille. + * `null` = aucune proposition en attente. + */ + gammeAValider: string | null; /** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */ network: NetworkMetricCached | null; /** @@ -73,7 +79,7 @@ export async function enrichRows(rows: ProductRow[]): Promise r.codeCentrale).filter((c): c is string => Boolean(c)))]; const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))]; - const [network, gammes] = await Promise.all([ + const [network, gammes, aValider] = await Promise.all([ codesCentraux.length ? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => { console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160)); @@ -86,6 +92,10 @@ export async function enrichRows(rows: ProductRow[]): Promise(); }) : Promise.resolve(new Map()), + gammesAValiderParArticle(codeins).catch((e) => { + console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160)); + return new Map(); + }), ]); return rows.map((row) => { @@ -97,6 +107,7 @@ export async function enrichRows(rows: ProductRow[]): Promise; compute: boolean; - /** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */ + /** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */ auteur: string; }): Promise { const { codeFournisseur, compute, auteur } = input; @@ -87,7 +95,8 @@ export async function affecterGammes(input: { } const resultats: ResultatGamme[] = []; - const changements: ChangementGamme[] = []; + const propositions: PropositionGamme[] = []; + const annules: string[] = []; let nomFournisseur: string | null = null; for (const [codein, gamme] of demandes) { @@ -100,27 +109,19 @@ export async function affecterGammes(input: { gammePrecedente: actuelle.codeGamme, codeGammeServeur: actuelle.codeGammeInit, modifie, + statut: modifie ? "a_valider" : "inchangee", }); - if (modifie) { - // Même « avant » que la Grille : l'état serveur, à défaut la gamme courante. - changements.push({ codein, codeGammeBefore: actuelle.codeGammeInit ?? actuelle.codeGamme, codeGamme: gamme }); - } + if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme }); + else annules.push(codein); } try { - await enregistrerGammes({ - codeFournisseur, - nomFournisseur, - magasin: "TOTAL", - changes: changements, - userId: null, - label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`, - }); + await proposerGammes({ codeFournisseur, propositions, annules, auteur }); } catch (e) { - console.error(`[api/v1/gammes] enregistrement KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); - return fail("internal_error", "L'enregistrement des gammes a échoué."); + console.error(`[api/v1/gammes] proposition KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); + return fail("internal_error", "L'enregistrement des gammes à valider a échoué."); } - console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`); + console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`); return { codeFournisseur, nomFournisseur, resultats }; } diff --git a/src/lib/gammes-a-valider.ts b/src/lib/gammes-a-valider.ts new file mode 100644 index 0000000..4ba91d1 --- /dev/null +++ b/src/lib/gammes-a-valider.ts @@ -0,0 +1,104 @@ +/** + * CollectFlow — Gammes proposées par l'API, en attente de validation. + * + * L'API `/api/v1` ne change plus une gamme directement : elle dépose une + * proposition, que la Grille affiche comme une modification non enregistrée. + * L'utilisateur voit ainsi quels produits changent de gamme, puis valide + * (« Enregistrer ») ou rejette (« Annuler les modifications »). + */ + +import "server-only"; + +import { and, eq, inArray, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { gammesAValider } from "@/db/schema"; + +export interface PropositionGamme { + codein: string; + /** Gamme proposée. */ + gamme: string; + /** Gamme enregistrée au moment de la proposition. */ + gammePrecedente: string | null; +} + +export interface GammeAValider extends PropositionGamme { + auteur: string | null; + /** Date ISO de la proposition. */ + proposeeLe: string; +} + +/** + * Dépose des propositions (la dernière l'emporte pour un même article) et retire + * celles des articles `annules` — redemander la gamme déjà enregistrée annule la + * proposition en attente. + */ +export async function proposerGammes(input: { + codeFournisseur: string; + propositions: PropositionGamme[]; + annules: string[]; + auteur: string; +}): Promise { + const { codeFournisseur, propositions, annules, auteur } = input; + await db.transaction(async (tx) => { + if (annules.length > 0) { + await tx + .delete(gammesAValider) + .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, annules))); + } + if (propositions.length > 0) { + const maintenant = new Date(); + await tx + .insert(gammesAValider) + .values(propositions.map((p) => ({ + codeFournisseur, + codein: p.codein, + gamme: p.gamme, + gammePrecedente: p.gammePrecedente, + auteur: auteur.slice(0, 100), + createdAt: maintenant, + }))) + .onConflictDoUpdate({ + target: [gammesAValider.codeFournisseur, gammesAValider.codein], + set: { + gamme: sql`excluded.gamme`, + gammePrecedente: sql`excluded.gamme_precedente`, + auteur: sql`excluded.auteur`, + createdAt: sql`excluded.created_at`, + }, + }); + } + }); +} + +/** Propositions en attente pour un fournisseur. */ +export async function listerGammesAValider(codeFournisseur: string): Promise { + const rows = await db + .select() + .from(gammesAValider) + .where(eq(gammesAValider.codeFournisseur, codeFournisseur)); + return rows.map((r) => ({ + codein: r.codein, + gamme: r.gamme, + gammePrecedente: r.gammePrecedente, + auteur: r.auteur, + proposeeLe: r.createdAt.toISOString(), + })); +} + +/** Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. */ +export async function gammesAValiderParArticle(codeins: string[]): Promise> { + if (codeins.length === 0) return new Map(); + const rows = await db + .select({ codeFournisseur: gammesAValider.codeFournisseur, codein: gammesAValider.codein, gamme: gammesAValider.gamme }) + .from(gammesAValider) + .where(inArray(gammesAValider.codein, codeins)); + return new Map(rows.map((r) => [`${r.codeFournisseur}|${r.codein}`, r.gamme])); +} + +/** Retire des propositions, une fois validées ou rejetées dans la Grille. */ +export async function supprimerGammesAValider(codeFournisseur: string, codeins: string[]): Promise { + if (codeins.length === 0) return; + await db + .delete(gammesAValider) + .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins))); +} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 72d1169..5cbe396 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -880,6 +880,8 @@ export async function pgGetCaByNomenclature( export interface HitParadeRow { codein: string; libelle: string; + /** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */ + reference: string; fournisseur: string; nomenclature_code: string; nomenclature: string; @@ -929,12 +931,13 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle, + COALESCE(TRIM(af.reference::text), '') AS reference, COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, COALESCE(n.code, '')::text AS nomenclature_code, COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature FROM articles a LEFT JOIN LATERAL ( - SELECT af1.code + SELECT af1.code, af1.reference FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code @@ -959,6 +962,7 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis SELECT v.codein, COALESCE(ar.libelle, '') AS libelle, + COALESCE(ar.reference, '') AS reference, COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, COALESCE(ar.nomenclature_code, '') AS nomenclature_code, COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,