Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
This commit is contained in:
Claude committed 2026-10-06 09:27:07 +00:00
1 parent efd6c00cf0
commit b373dabeaa
21 files changed
+413 -73

No files matched your search

@@ -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)" },
];
@@ -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" };
}
}
+8 -1
View File
@@ -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";
@@ -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<string | null>(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
</DropdownMenuContent>
</DropdownMenu>
{nbAValider > 0 && (
<span
className="rounded-lg border px-2.5 py-1 text-[13px] font-medium border-[var(--accent-warning)] text-[var(--accent-warning)]"
title="Gammes proposées par l'API : elles apparaissent comme des modifications non enregistrées. « Enregistrer » les valide, « Annuler » les rejette."
>
{nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l&apos;API à valider
</span>
)}
{hasDrafts && (
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
<RotateCcw /> Annuler
@@ -265,9 +296,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
{isPending
? "Enregistrement…"
: hasDrafts
: count > 0
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
: "Aucune modification"}
: hasDrafts
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
: "Aucune modification"}
</Button>
</div>
</div>
@@ -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);
@@ -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 && (
<div
className="absolute -right-2 -top-2 z-10 rounded px-1 text-[9px] font-bold leading-[14px] text-white shadow-sm bg-[var(--accent-warning)]"
title={`Proposée par l'API${proposition.auteur ? ` (${proposition.auteur})` : ""} le ${new Date(proposition.proposeeLe).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} — à valider`}
>
API
</div>
)}
{isModified && !isDraft && (
<div className="absolute -right-2 -top-2 bg-emerald-500 text-white rounded-full p-0.5 shadow-sm z-10 animate-in zoom-in-50" title="Modifié et validé">
<Check className="w-2.5 h-2.5" />
+15 -5
View File
@@ -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 };
}
+45
View File
@@ -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<string, ProductRow>;
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
/**
* Propositions de l'API en attente pour le fournisseur affiché (codein →
* proposition). Non persisté : relu sur le serveur à chaque chargement.
*/
gammesAValider: Record<string, GammeProposee>;
/**
* 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<GridState>()(
rowsByCodein: {},
activeMagasin: "TOTAL",
draftChanges: {},
gammesAValider: {},
filters: {
magasin: null,
codeFournisseur: null,
@@ -211,6 +234,28 @@ export const useGridStore = create<GridState>()(
}, 80);
},
chargerGammesAValider: (propositions) => {
const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
const gammesAValider: Record<string, GammeProposee> = {};
const nouvelles: Record<string, GammeCode> = {};
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);