Compare commits

..
Author SHA1 Message Date
Claude cf65968431 Exports : reprendre les gammes changées par l'API avant d'exporter
Les exports et la copie dans l'Historique partaient des lignes chargées dans
le navigateur. Une gamme changée par l'API après le chargement n'y figurait
pas, et la copie (qui devient le dernier snapshot, celui que la Grille
réapplique) l'effaçait au rechargement suivant.

- Avant chaque export ou copie, l'écran relit les gammes enregistrées
  (dernier snapshot) et met à jour les lignes concernées.
- saveSnapshot reprend les gammes enregistrées depuis cet alignement au lieu
  de les écraser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011twCi6DCZ9xTpEpTACiBuN
2026-10-05 13:35:10 +00:00
29 changed files with 389 additions and 729 deletions

No files matched your search

-14
View File
@@ -212,20 +212,6 @@ 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);
+25 -13
View File
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useTransition, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { Segmented, type TabItem } from "@/components/ui/tabs";
@@ -84,6 +84,16 @@ function Montant({ v }: { v: number }) {
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>;
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
return (
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span className="inline-flex items-center gap-1">{children}</span>
</span>
);
}
export function AnalyticsClient({
mode,
mois,
@@ -116,40 +126,42 @@ export function AnalyticsClient({
sortValue: (r) => r.label,
cell: (r) => <span className="font-medium">{r.label}</span>,
footer: () => "Total",
className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
label: true,
className: "min-w-[220px]",
},
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
const total = g.id === "total";
// En-tête commun, séparation et teinte du total : gérés par DataTable.
const commun = { group: g.titre, highlight: total, align: "right" as const };
const bord = "border-l border-[var(--border)]";
return [
{
...commun,
id: `${g.id}-ca`,
header: libelleMois,
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
align: "right",
sortValue: (r) => r[g.ca],
cell: (r) => <Montant v={r[g.ca]} />,
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
className: cn(total && "font-semibold"),
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
},
{
...commun,
id: `${g.id}-n1`,
header: <span className="inline-flex items-center gap-1">{libelleMoisN1} · <Terme id="n1" /></span>,
header: (
<EnTete groupe={g.titre}>
{libelleMoisN1} · <Terme id="n1" />
</EnTete>
),
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`,
align: "right",
sortValue: (r) => r[g.caN1],
cell: (r) => <Montant v={r[g.caN1]} />,
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
className: cn(total && "font-semibold"),
},
{
...commun,
id: `${g.id}-evolution`,
header: "Évolution",
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`,
align: "right",
sortValue: (r) => evolution(r[g.ca], r[g.caN1]),
cell: (r) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
+1 -1
View File
@@ -141,7 +141,7 @@ export default async function AnalyticsPage(props: {
}
return (
<div className="w-full min-w-0">
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={CalendarRange}
title="Ventes par mois"
+30 -31
View File
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useState, useTransition, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { Input, Label } from "@/components/ui/form-controls";
@@ -68,45 +68,56 @@ function CelluleStock({ v }: { v: number }) {
);
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
return (
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span>{children}</span>
</span>
);
}
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
const total = g.id === "total";
// En-tête commun, séparation et teinte du total : gérés par DataTable.
const commun = { group: g.titre, highlight: total, align: "right" as const };
const bord = "border-l border-[var(--border)]";
return [
{
...commun,
id: `${g.id}-qte`,
header: "Qté",
header: <EnTete groupe={g.titre}>Qté</EnTete>,
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.qte],
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
footer: rows => fmtEntier(somme(rows, g.qte)),
className: cn(total && "font-semibold"),
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
},
{
...commun,
id: `${g.id}-ca`,
header: "CA TTC",
header: <EnTete groupe={g.titre}>CA TTC</EnTete>,
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.ca],
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
footer: rows => fmtEur2(somme(rows, g.ca)),
className: cn(total && "font-semibold"),
className: cn("whitespace-nowrap", total && "font-semibold"),
},
{
...commun,
id: `${g.id}-marge`,
header: "% marge",
header: <EnTete groupe={g.titre}>% marge</EnTete>,
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`,
align: "right",
sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
cell: r => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
className: "whitespace-nowrap",
},
{
...commun,
id: `${g.id}-stock`,
header: "Stock",
header: <EnTete groupe={g.titre}>Stock</EnTete>,
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
align: "right",
sortValue: r => r[g.stock],
cell: r => <CelluleStock v={r[g.stock]} />,
footer: rows => fmtEntier(somme(rows, g.stock)),
@@ -122,7 +133,6 @@ const COLONNES: DataColumn<Ligne>[] = [
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
footer: () => "Total",
className: "whitespace-nowrap",
sticky: true,
},
{
id: "libelle",
@@ -130,17 +140,7 @@ const COLONNES: DataColumn<Ligne>[] = [
sortValue: r => r.libelle.trim(),
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
label: true,
},
{
id: "reference",
header: "Référence",
hint: "Référence fournisseur de l'article",
sortValue: r => r.reference,
cell: r => (r.reference ? <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.reference}</span> : vide),
className: "whitespace-nowrap",
className: "min-w-[220px]",
},
{
id: "fournisseur",
@@ -170,7 +170,7 @@ const FILTRES: DataFilter<Ligne>[] = [
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
];
const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
const recherche = (r: Ligne) => [r.codein, r.libelle];
interface Props {
dateDebut: string;
@@ -204,22 +204,21 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
feuille: "Meilleures ventes",
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
entetes: [
"Code", "Désignation", "Référence", "Fournisseur", "Nomenclature",
"Code", "Désignation", "Fournisseur", "Nomenclature",
...GROUPES.flatMap(g => [
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
]),
],
largeurs: [12, 40, 18, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
lignes: rows.map(r => [
r.codein,
r.libelle.trim(),
r.reference,
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)];
@@ -267,7 +266,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
columns={COLONNES}
rowKey={r => r.codein}
searchIn={recherche}
searchPlaceholder="Code, référence ou désignation…"
searchPlaceholder="Code ou désignation…"
filters={FILTRES}
pageSize={100}
unite="produits"
+1 -4
View File
@@ -13,8 +13,6 @@ 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;
@@ -40,7 +38,6 @@ 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,
@@ -103,7 +100,7 @@ export default async function HitParadePage(props: {
}
return (
<div className="w-full min-w-0">
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={Trophy}
title="Meilleures ventes"
+1 -5
View File
@@ -76,7 +76,7 @@ function StatutBadge({ statut }: { statut?: string }) {
/** Intitulé d'abord, code de l'opération en petit dessous. */
function Operation({ intitule, code }: { intitule: string; code: string }) {
return (
<div className="min-w-[140px] sm:min-w-[220px]">
<div className="min-w-[220px]">
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
</div>
@@ -171,8 +171,6 @@ export function PublicitesClient({
header: "Opération",
sortValue: (p) => p.intitule,
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
sticky: true,
label: true,
footer: (rows) =>
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
},
@@ -263,8 +261,6 @@ export function PublicitesClient({
header: "Opération",
sortValue: (h) => h.intitule,
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />,
sticky: true,
label: true,
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
},
{
+1 -1
View File
@@ -120,7 +120,7 @@ export default async function PublicitesPage(props: {
]);
return (
<div className="w-full min-w-0">
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={Megaphone}
title="Publicités"
+2 -3
View File
@@ -12,8 +12,8 @@ export const maxDuration = 300;
/**
* POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }
*
* Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les
* propositions restent **à valider** dans la Grille (cf. lib/api-gammes).
* Affecte ou change la gamme de plusieurs articles d'un fournisseur en un appel —
* un seul snapshot enregistré, au lieu d'un par article avec /products/:codein/gamme.
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
*/
export async function POST(req: NextRequest) {
@@ -42,7 +42,6 @@ export async function POST(req: NextRequest) {
nomFournisseur: res.nomFournisseur,
demandes: res.resultats.length,
modifies: res.resultats.filter((r) => r.modifie).length,
aValider: res.resultats.filter((r) => r.statut === "a_valider").length,
enregistreLe: new Date().toISOString(),
},
});
+14 -19
View File
@@ -72,11 +72,9 @@ 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) "
+ "**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.",
+ "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.",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
@@ -153,8 +151,7 @@ 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 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." },
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
@@ -180,11 +177,10 @@ export async function GET(req: NextRequest) {
type: "object",
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." },
gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." },
gamme: { type: "string", enum: codesGamme, description: "Gamme désormais affectée." },
gammePrecedente: { type: ["string", "null"], description: "Gamme courante avant l'appel." },
codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." },
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." },
modifie: { type: "boolean", description: "false = l'article avait déjà cette gamme, rien n'a été écrit." },
},
},
Trend: {
@@ -394,10 +390,9 @@ export async function GET(req: NextRequest) {
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
summary: "Proposer la gamme d'un produit (à valider)",
summary: "Affecter ou changer la gamme d'un produit",
description:
"Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît "
+ "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". "
"Enregistre la gamme d'un article, comme la Grille. 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.",
@@ -422,7 +417,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
description: "Gamme proposée, à valider",
description: "Gamme enregistrée",
content: {
"application/json": {
schema: {
@@ -449,10 +444,10 @@ export async function GET(req: NextRequest) {
"/gammes": {
post: {
operationId: "changerGammes",
summary: "Proposer la gamme de plusieurs produits (à valider)",
summary: "Affecter ou changer la gamme de plusieurs produits",
description:
"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é "
"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é "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".",
requestBody: {
@@ -485,7 +480,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
description: "Gammes proposées, à valider",
description: "Gammes enregistrées",
content: {
"application/json": {
schema: {
@@ -13,9 +13,8 @@ export const maxDuration = 300;
/**
* PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" }
*
* 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).
* 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`.
*
* `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é.
+1 -32
View File
@@ -578,35 +578,4 @@
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
/* ── Tableau de données commun (components/ui/data-table.tsx) ──
Fonds opaques partout : les colonnes figées recouvrent les cellules qui
défilent dessous. La ligne porte la couleur, les cellules en héritent. */
.dt-table {
/* Une ligne sur deux, plus soutenue : suivre une ligne sur un écran large. */
--dt-zebra: color-mix(in srgb, var(--text-primary) 3.5%, var(--bg-surface));
/* Survol teinté de la couleur d'accent : la ligne pointée se distingue de l'alternance. */
--dt-hover: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
}
.dt-row {
background-color: var(--bg-surface);
}
.dt-row:nth-child(even) {
background-color: var(--dt-zebra);
}
.dt-row:hover {
background-color: var(--dt-hover);
}
.dt-sticky {
position: sticky;
z-index: 1;
background-color: inherit;
}
/* Ombre portée sur la dernière colonne figée : on voit ce qui passe dessous. */
.dt-sticky-last {
box-shadow: inset -1px 0 0 var(--border-strong), 4px 0 6px -4px rgba(0, 0, 0, 0.25);
}
/* Teinte superposée au fond de la ligne (alternance et survol conservés). */
.dt-highlight {
background-image: linear-gradient(var(--accent-bg), var(--accent-bg));
}
}
+54 -172
View File
@@ -8,7 +8,7 @@
* en réimplémentaient chacune une partie (13 tableaux faits à la main).
*/
import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useDeferredValue, useMemo, useState, type ReactNode } from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { SearchInput, Select } from "@/components/ui/form-controls";
@@ -33,26 +33,6 @@ export interface DataColumn<T> {
footer?: (rows: readonly T[]) => ReactNode;
className?: string;
headerClassName?: string;
/**
* Colonne de libellé (désignation, fournisseur…) : largeur naturelle, avec
* retour à la ligne au-delà de 28rem. Le tableau ne s'étire jamais au-delà
* de son contenu : sur un écran très large, le libellé reste collé à ses
* chiffres au lieu d'en être séparé par un grand vide.
*/
label?: boolean;
/**
* Colonne figée à gauche pendant le défilement horizontal. Seules les
* premières colonnes consécutives peuvent l'être.
*/
sticky?: boolean;
/**
* Groupe de colonnes (un magasin, « Total »…) : un en-tête commun est
* affiché au-dessus des colonnes consécutives du même groupe, et un trait
* vertical sépare les groupes.
*/
group?: string;
/** Fond légèrement teinté, pour faire ressortir un groupe (le total…). */
highlight?: boolean;
}
export interface DataFilter<T> {
@@ -218,130 +198,6 @@ export function DataTable<T>({
const alignement = (a?: "left" | "right" | "center") =>
a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left";
// Colonnes figées : seulement les premières, consécutives.
let nbFigees = 0;
while (nbFigees < columns.length && columns[nbFigees].sticky) nbFigees++;
// Début de groupe : trait vertical à gauche de la colonne.
const debutGroupe = (i: number) => Boolean(columns[i].group) && columns[i - 1]?.group !== columns[i].group;
const avecGroupes = columns.some((c) => c.group);
// Décalage gauche de chaque colonne figée = largeur cumulée des précédentes,
// mesurée sur l'en-tête et exposée en variables CSS sur le tableau.
const tableRef = useRef<HTMLTableElement>(null);
const [decalages, setDecalages] = useState<number[]>([]);
useLayoutEffect(() => {
const table = tableRef.current;
if (!table || nbFigees === 0) return;
const mesurer = () => {
const out: number[] = [];
let gauche = 0;
for (let i = 0; i < nbFigees; i++) {
out.push(gauche);
const th = table.querySelector<HTMLElement>(`thead th[data-col="${i}"]`);
gauche += th?.getBoundingClientRect().width ?? 0;
}
setDecalages((prev) => (prev.length === out.length && prev.every((v, i) => v === out[i]) ? prev : out));
};
mesurer();
const obs = new ResizeObserver(mesurer);
obs.observe(table);
return () => obs.disconnect();
}, [nbFigees, total, columns]);
const styleFigee = (i: number): CSSProperties | undefined =>
i < nbFigees ? { left: decalages[i] ?? 0 } : undefined;
/** Classes communes à toutes les cellules d'une colonne (en-tête, corps, totaux). */
const classesColonne = (c: DataColumn<T>, i: number) => cn(
i < nbFigees && "dt-sticky",
i === nbFigees - 1 && "dt-sticky-last",
c.highlight && "dt-highlight",
debutGroupe(i) && "border-l border-[var(--border-strong)]",
);
/** Contenu d'une cellule de libellé, borné en largeur (cf. `label`). */
const borne = (c: DataColumn<T>, i: number, contenu: ReactNode) =>
c.label ? (
// Libellé figé : plus étroit sur mobile pour laisser voir les chiffres.
<div className={i < nbFigees ? "max-w-[45vw] md:max-w-[28rem]" : "max-w-[28rem]"}>{contenu}</div>
) : contenu;
const enTete = (c: DataColumn<T>, i: number, rowSpan?: number) => {
const triable = Boolean(c.sortValue);
const actif = tri?.id === c.id;
const contenu = (
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
{c.header}
{triable && (
actif
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</span>
);
return (
<th
key={c.id}
scope="col"
data-col={i}
rowSpan={rowSpan}
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
style={styleFigee(i)}
className={cn(
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)] align-bottom",
alignement(c.align),
classesColonne(c, i),
c.headerClassName,
)}
>
{triable ? (
<button
type="button"
onClick={() => basculerTri(c.id)}
className="hover:text-[var(--text-primary)]"
title={typeof c.hint === "string" ? c.hint : undefined}
>
{contenu}
</button>
) : c.hint ? (
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
) : contenu}
</th>
);
};
// Première ligne d'en-tête quand il y a des groupes : un titre par groupe,
// et les colonnes sans groupe sur deux lignes.
const ligneGroupes: ReactNode[] = [];
if (avecGroupes) {
for (let i = 0; i < columns.length;) {
const c = columns[i];
if (!c.group) {
ligneGroupes.push(enTete(c, i, 2));
i++;
continue;
}
let j = i;
while (j < columns.length && columns[j].group === c.group) j++;
ligneGroupes.push(
<th
key={`groupe-${i}`}
scope="colgroup"
colSpan={j - i}
className={cn(
"whitespace-nowrap border-b border-l border-[var(--border)] px-3 pt-2 pb-1 text-center text-[13px] font-semibold text-[var(--text-primary)]",
"border-l-[var(--border-strong)]",
columns.slice(i, j).every((x) => x.highlight) && "dt-highlight",
)}
>
{c.group}
</th>,
);
i = j;
}
}
return (
<div className={cn("space-y-3", className)}>
{/* Barre d'outils */}
@@ -393,22 +249,52 @@ export function DataTable<T>({
/>
) : (
<div
// Largeur du contenu, sans dépasser l'écran (au-delà : défilement).
className="w-fit max-w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
style={{ maxHeight }}
>
<table ref={tableRef} className="dt-table text-sm">
<thead className="sticky top-0 z-10">
{avecGroupes ? (
<>
<tr className="bg-[var(--bg-elevated)]">{ligneGroupes}</tr>
<tr className="bg-[var(--bg-elevated)]">
{columns.map((c, i) => (c.group ? enTete(c, i) : null))}
</tr>
</>
) : (
<tr className="bg-[var(--bg-elevated)]">{columns.map((c, i) => enTete(c, i))}</tr>
)}
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]">
<tr>
{columns.map((c) => {
const triable = Boolean(c.sortValue);
const actif = tri?.id === c.id;
const contenu = (
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
{c.header}
{triable && (
actif
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</span>
);
return (
<th
key={c.id}
scope="col"
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
className={cn(
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)]",
alignement(c.align),
c.headerClassName,
)}
>
{triable ? (
<button
type="button"
onClick={() => basculerTri(c.id)}
className="hover:text-[var(--text-primary)]"
title={typeof c.hint === "string" ? c.hint : undefined}
>
{contenu}
</button>
) : c.hint ? (
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
) : contenu}
</th>
);
})}
</tr>
</thead>
<tbody>
{lignesAffichees.map((r, i) => (
@@ -416,43 +302,39 @@ export function DataTable<T>({
key={rowKey(r, i)}
onClick={onRowClick ? () => onRowClick(r) : undefined}
className={cn(
"dt-row border-b border-[var(--border)] last:border-b-0",
"border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
onRowClick && "cursor-pointer",
)}
>
{columns.map((c, ci) => (
{columns.map((c) => (
<td
key={c.id}
style={styleFigee(ci)}
className={cn(
"px-3 py-2 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums whitespace-nowrap",
c.align === "right" && "text-right tabular-nums",
c.align === "center" && "text-center",
classesColonne(c, ci),
c.className,
)}
>
{borne(c, ci, c.cell(r))}
{c.cell(r)}
</td>
))}
</tr>
))}
</tbody>
{showFooter && (
<tfoot className="sticky bottom-0 z-10 font-semibold">
<tr className="bg-[var(--bg-elevated)]">
{columns.map((c, ci) => (
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
<tr>
{columns.map((c) => (
<td
key={c.id}
style={styleFigee(ci)}
className={cn(
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums whitespace-nowrap",
c.align === "right" && "text-right tabular-nums",
c.align === "center" && "text-center",
classesColonne(c, ci),
)}
>
{c.footer ? borne(c, ci, c.footer(triees)) : null}
{c.footer ? c.footer(triees) : null}
</td>
))}
</tr>
+1 -24
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core";
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
@@ -283,26 +283,3 @@ 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] }),
]);
@@ -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: "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: "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: "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)" },
];
+20 -11
View File
@@ -13,6 +13,25 @@ export interface ChangementGamme {
codeGamme: string;
}
/** Gammes d'un snapshot : codein → gamme avant (état FF) / après. */
export type GammesSnapshot = Record<string, { before: string | null; after: string }>;
/**
* Dernier snapshot du fournisseur — celui que la Grille réapplique à chaque calcul
* (Phase 9). `null` s'il n'y en a aucun ; lève une erreur si la lecture échoue.
*/
export async function lireDernierSnapshot(
codeFournisseur: string,
): Promise<{ id: number; changes: GammesSnapshot } | null> {
const [dernier] = await db
.select({ id: sessionSnapshots.id, changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
return dernier ? { id: dernier.id, changes: (dernier.changes ?? {}) as GammesSnapshot } : null;
}
export interface EnregistrementGammes {
codeFournisseur: string;
nomFournisseur?: string | null;
@@ -35,17 +54,7 @@ export async function enregistrerGammes(input: EnregistrementGammes): Promise<nu
if (changes.length === 0) return 0;
// Charger le dernier snapshot existant pour merger les changements
const existing = await db
.select()
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
const prevChanges: Record<string, { before: string | null; after: string }> =
existing.length > 0
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
: {};
const prevChanges = (await lireDernierSnapshot(codeFournisseur))?.changes ?? {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
@@ -1,37 +0,0 @@
"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" };
}
}
@@ -0,0 +1,37 @@
"use server";
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { lireDernierSnapshot } from "./enregistrer-gammes";
/**
* Gammes enregistrées pour un fournisseur (dernier snapshot), quelle qu'en soit
* l'origine : la Grille, ou l'API /api/v1 pendant que la Grille était ouverte.
*
* `snapshotId` identifie l'état lu : `saveSnapshot` s'en sert pour reprendre les
* gammes enregistrées APRÈS cette lecture au lieu de les écraser.
*/
export async function lireGammesEnregistrees(
raw: unknown,
): Promise<
| { success: true; snapshotId: number | null; gammes: Record<string, string> }
| { success: false; error: string }
> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = z.string().min(1).safeParse(raw);
if (!parsed.success) return { success: false, error: "Code fournisseur manquant." };
try {
const dernier = await lireDernierSnapshot(parsed.data);
const gammes: Record<string, string> = {};
for (const [codein, delta] of Object.entries(dernier?.changes ?? {})) {
if (delta?.after) gammes[codein] = delta.after;
}
return { success: true, snapshotId: dernier?.id ?? null, gammes };
} catch (err) {
console.error("[lireGammesEnregistrees]", err instanceof Error ? err.message : String(err));
return { success: false, error: "lecture des gammes enregistrées impossible" };
}
}
+1 -8
View File
@@ -3,7 +3,6 @@
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(),
@@ -16,11 +15,6 @@ 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(
@@ -34,7 +28,7 @@ export async function saveDraftChanges(
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data;
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -42,7 +36,6 @@ 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,7 +8,6 @@ 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,
@@ -27,10 +26,12 @@ import {
exporterGammeA,
exporterTousProduits,
imprimerPdf,
LIGNES_MAX_PAR_FICHIER_GAMMES,
lignesModifiees,
synchroniserGammes,
} from "@/features/grid/lib/exports";
type Synchro = Awaited<ReturnType<typeof synchroniserGammes>>;
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
<div className="flex flex-col leading-tight">
@@ -43,14 +44,6 @@ function Stat({ label, value, sub, subColor }: { label: string; value: string; s
);
}
/** Prévient quand le fichier d'import des gammes a été découpé ; vrai si quelque chose a été exporté. */
function signalerDecoupage(nbFichiers: number): boolean {
if (nbFichiers > 1) {
toast.info(`Export découpé en ${nbFichiers} fichiers de ${fmtEntier(LIGNES_MAX_PAR_FICHIER_GAMMES)} lignes maximum : intégrez-les un par un.`);
}
return nbFichiers > 0;
}
function messageErreur(err: unknown): string {
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
return err instanceof Error ? err.message : String(err);
@@ -72,7 +65,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null);
const { save, hasDrafts, count, nbAValider } = useSaveDrafts(activeMagasin || "TOTAL");
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
@@ -87,9 +80,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
startTransition(async () => {
try {
const result = await save();
if (result.success && result.saved === 0) {
toast.info("Propositions de l'API écartées.");
} else if (result.success) {
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"}`);
@@ -101,65 +92,65 @@ 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: 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,
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.`,
libelleConfirmer: "Annuler les modifications",
danger: true,
});
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;
}
if (ok) {
resetDrafts();
toast.info("Modifications annulées.");
}
resetDrafts();
toast.info("Modifications annulées.");
};
/**
* Gammes enregistrées depuis le chargement (API /api/v1, autre onglet) : l'écran
* les reprend avant tout export, sinon le fichier et l'Historique partaient de
* gammes périmées — et la copie dans l'Historique les effaçait.
*/
const alignerSurServeur = async (): Promise<Synchro> => {
if (!supplierCode) return { snapshotId: null, misesAJour: 0 };
const synchro = await synchroniserGammes(supplierCode);
if (synchro.misesAJour > 0) {
const n = synchro.misesAJour;
toast.info(`${n} gamme${n > 1 ? "s ont" : " a"} été modifiée${n > 1 ? "s" : ""} entre-temps côté serveur (API) : l'écran a été mis à jour.`);
}
return synchro;
};
/**
* Enregistre une copie de la session dans l'Historique.
* Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé).
* `synchro` : alignement déjà fait par l'appelant, sinon il est fait ici.
*/
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot", synchro?: Synchro) => {
if (rows.length === 0) return null;
const { draftChanges } = useGridStore.getState();
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
try {
const { snapshotId: baseSnapshotId } = synchro ?? await alignerSurServeur();
// Relu après l'alignement : les lignes et les totaux ont pu changer.
const { draftChanges, rows: lignes, summary: totaux } = useGridStore.getState();
if (lignes.length === 0) return null;
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
const res = await saveSnapshot({
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
nomFournisseur: rows[0].nomFournisseur,
codeFournisseur: filterFournisseur || lignes[0].codeFournisseur,
nomFournisseur: lignes[0].nomFournisseur,
magasin: activeMagasin || "TOTAL",
label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
label: labelOverride || `Session ${lignes[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
changes,
type,
baseSnapshotId,
summary: {
totalRows: summary.totalRows,
totalCa: summary.totalCa,
totalMarge: summary.totalMarge,
tauxMargeGlobal: summary.tauxMargeGlobal,
totalRows: totaux.totalRows,
totalCa: totaux.totalCa,
totalMarge: totaux.totalMarge,
tauxMargeGlobal: totaux.tauxMargeGlobal,
},
});
if (!res.success) throw new Error(res.error);
@@ -171,12 +162,12 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}
};
/** Lance un export avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
/** Lance un export (écran aligné sur le serveur) avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: (synchro: Synchro) => Promise<boolean>, vide: string) => {
if (enCours) return;
setEnCours(cle);
try {
const ok = await action();
const ok = await action(await alignerSurServeur());
if (!ok) toast.info(vide);
} catch (err) {
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
@@ -221,12 +212,12 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Fichier d&apos;import des gammes</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("modifs", async () => {
onSelect={() => lancer("modifs", async (synchro) => {
if (lignesModifiees().length === 0) return false;
const nom = lignesModifiees()[0].nomFournisseur;
// Copie dans l'Historique, onglet « Exports »
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
return exporterFichierGammes(true).then(signalerDecoupage);
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export", synchro);
return exporterFichierGammes(true);
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
>
<FileSpreadsheet />
@@ -237,7 +228,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("complet", () => exporterFichierGammes(false).then(signalerDecoupage), "Aucun produit à exporter.")}
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
>
<FileSpreadsheet />
<div className="flex flex-col">
@@ -288,14 +279,6 @@ 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
@@ -305,11 +288,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
{isPending
? "Enregistrement…"
: count > 0
: hasDrafts
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
: hasDrafts
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
: "Aucune modification"}
: "Aucune modification"}
</Button>
</div>
</div>
@@ -7,7 +7,6 @@ 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";
@@ -232,26 +231,6 @@ 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,9 +244,6 @@ 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;
@@ -270,14 +267,6 @@ 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" />
+5 -15
View File
@@ -23,15 +23,9 @@ 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, rows, gammesAValider, applyDraftsToRows, clearDrafts, retirerGammesAValider } = useGridStore.getState();
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = 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.
@@ -43,18 +37,15 @@ 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 && gammesAValiderTraitees.length === 0) return { success: true, saved: 0 };
if (changes.length === 0) return { success: true, saved: 0 };
// Fournisseur déduit de la première ligne modifiée
const firstRow = rowsByCodein[changes[0]?.codein ?? gammesAValiderTraitees[0]] ?? rows[0];
const firstRow = rowsByCodein[changes[0].codein];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees });
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
if (result.success) {
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
@@ -62,10 +53,9 @@ export function useSaveDrafts(magasin: string) {
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
clearDrafts(Object.keys(activeDrafts));
retirerGammesAValider(gammesAValiderTraitees);
}
return result;
}, [magasin]);
return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider };
return { save, hasDrafts: count > 0, count };
}
+30 -33
View File
@@ -3,10 +3,12 @@
*
* Il y avait deux menus d'export (en-tête et barre du bas) aux droits différents.
* Les bibliothèques lourdes (exceljs, jspdf) ne sont chargées qu'au clic.
* Les lignes sont lues dans le store au moment de l'export.
* Les lignes sont lues dans le store au moment de l'export, après
* `synchroniserGammes` : l'API peut avoir changé des gammes depuis le chargement.
*/
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { lireGammesEnregistrees } from "@/features/grid/api/lire-gammes-enregistrees";
import type { ProductRow } from "@/types/grid";
function telecharger(blob: Blob, nomFichier: string) {
@@ -25,6 +27,20 @@ function gammeEffective(r: ProductRow, drafts: Record<string, string>): string |
return (drafts[r.codein] ?? r.codeGamme) as string | null;
}
/**
* Aligne les lignes affichées sur les gammes enregistrées côté serveur, sans
* recharger toute la Grille. À appeler avant un export ou une copie dans
* l'Historique. `snapshotId` est à transmettre à `saveSnapshot`.
*/
export async function synchroniserGammes(
codeFournisseur: string,
): Promise<{ snapshotId: number | null; misesAJour: number }> {
const res = await lireGammesEnregistrees(codeFournisseur);
if (!res.success) throw new Error(res.error);
const misesAJour = useGridStore.getState().applyServerGammes(res.gammes);
return { snapshotId: res.snapshotId, misesAJour };
}
/** Lignes dont la gamme diffère de la gamme enregistrée dans FF. */
export function lignesModifiees(): ProductRow[] {
const { rows, draftChanges } = useGridStore.getState();
@@ -32,24 +48,14 @@ export function lignesModifiees(): ProductRow[] {
}
/**
* Nombre maximal de produits par fichier d'import des gammes. Le logiciel de
* mise à jour des gammes refuse les fichiers trop gros (~28 000 lignes) : au-delà,
* l'export est découpé en plusieurs fichiers à intégrer un par un.
*/
export const LIGNES_MAX_PAR_FICHIER_GAMMES = 5000;
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Fichier d'import des gammes (CODE FOURNISSEUR, CODEIN, GAMME), généré par le
* Fichier d'import des gammes (CODE FOURNISSEUR, GENCOD, GAMME), généré par le
* serveur. `seulementModifiees` : uniquement les gammes changées.
* Découpé en plusieurs fichiers de `LIGNES_MAX_PAR_FICHIER_GAMMES` lignes au plus.
* Renvoie le nombre de fichiers téléchargés (0 s'il n'y a rien à exporter).
* Renvoie `false` s'il n'y a rien à exporter.
*/
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<number> {
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<boolean> {
const { rows, draftChanges } = useGridStore.getState();
const lignes = seulementModifiees ? lignesModifiees() : rows;
if (lignes.length === 0) return 0;
if (lignes.length === 0) return false;
const nomFournisseur = lignes[0].nomFournisseur;
const changes = lignes.map((r) => ({
@@ -59,25 +65,16 @@ export async function exporterFichierGammes(seulementModifiees: boolean): Promis
gamme: gammeEffective(r, draftChanges) as string,
}));
const res = await fetch("/api/export/modified-gammes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomFournisseur, changes }),
});
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
const prefixe = seulementModifiees ? "Modifications_Gammes" : "Export_Complet";
const base = `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}`;
const nbFichiers = Math.ceil(changes.length / LIGNES_MAX_PAR_FICHIER_GAMMES);
for (let i = 0; i < nbFichiers; i++) {
const paquet = changes.slice(i * LIGNES_MAX_PAR_FICHIER_GAMMES, (i + 1) * LIGNES_MAX_PAR_FICHIER_GAMMES);
const res = await fetch("/api/export/modified-gammes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomFournisseur, changes: paquet }),
});
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
const suffixe = nbFichiers > 1 ? `_partie_${i + 1}_sur_${nbFichiers}` : "";
telecharger(await res.blob(), `${base}${suffixe}.xlsx`);
// Les navigateurs ignorent les téléchargements trop rapprochés
if (i < nbFichiers - 1) await pause(500);
}
return nbFichiers;
telecharger(await res.blob(), `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}.xlsx`);
return true;
}
/** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */
+29 -45
View File
@@ -15,14 +15,6 @@ 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`;
@@ -33,20 +25,6 @@ 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";
@@ -102,6 +80,8 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
/** Reporte les gammes enregistrées côté serveur ; renvoie le nombre de lignes changées. */
applyServerGammes: (gammes: Record<string, string>) => number;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
@@ -163,7 +143,6 @@ export const useGridStore = create<GridState>()(
rowsByCodein: {},
activeMagasin: "TOTAL",
draftChanges: {},
gammesAValider: {},
filters: {
magasin: null,
codeFournisseur: null,
@@ -234,28 +213,6 @@ 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);
@@ -285,6 +242,33 @@ export const useGridStore = create<GridState>()(
});
set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
},
/**
* Gammes enregistrées hors de cet écran (API /api/v1, autre onglet) : les
* lignes prennent la valeur qu'un rechargement leur donnerait. Une
* modification en cours qui tombe sur cette valeur n'a plus lieu d'être.
*/
applyServerGammes: (gammes) => {
const { rows, draftChanges, activeMagasin } = get();
const changees: string[] = [];
const next = rows.map((r) => {
const gamme = gammes[r.codein];
if (gamme === undefined || gamme === r.codeGamme) return r;
changees.push(r.codein);
return { ...r, codeGamme: gamme as GammeCode };
});
if (changees.length === 0) return 0;
const drafts = { ...draftChanges };
for (const codein of changees) {
if (drafts[codein] === gammes[codein]) delete drafts[codein];
}
set({
rows: next,
rowsByCodein: indexRows(next),
draftChanges: drafts,
summary: computeSummary(next, drafts, activeMagasin),
});
return changees.length;
},
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
+48 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
import { lireDernierSnapshot, type GammesSnapshot } from "@/features/grid/api/enregistrer-gammes";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
@@ -17,6 +18,11 @@ const SaveSnapshotSchema = z.object({
after: z.string(),
})),
type: z.enum(["snapshot", "export"]).optional(),
/**
* Snapshot sur lequel l'écran s'est aligné juste avant (cf. lireGammesEnregistrees),
* `null` s'il n'y en avait aucun. Absent : tout le dernier snapshot est repris.
*/
baseSnapshotId: z.number().int().nullable().optional(),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
@@ -35,7 +41,7 @@ export async function saveSnapshot(raw: unknown) {
return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const { codeFournisseur, nomFournisseur, magasin, label, summary, type, baseSnapshotId } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -43,6 +49,16 @@ export async function saveSnapshot(raw: unknown) {
const finalUserId = (userId && !isNaN(userId)) ? userId : null;
// Ce snapshot devient le dernier, celui que la Grille réapplique : il ne doit
// pas effacer les gammes enregistrées depuis que l'écran s'est aligné (API).
let changes = parsed.data.changes;
try {
changes = await reprendreGammesRecentes(codeFournisseur, changes, baseSnapshotId);
} catch (err) {
// Table absente ou base indisponible : l'insertion ci-dessous le dira.
console.error("[saveSnapshot] lecture du dernier snapshot KO:", err);
}
try {
const [created] = await db
.insert(sessionSnapshots)
@@ -112,3 +128,34 @@ export async function saveSnapshot(raw: unknown) {
}
}
}
/**
* Ajoute aux gammes de l'écran celles enregistrées depuis le snapshot `baseSnapshotId`
* (sur lequel l'écran s'est aligné) : ce sont des écritures que l'écran n'a pas vues,
* typiquement par l'API. Les autres gammes du dernier snapshot, l'écran les
* connaissait : son état (retours à la gamme FF compris) fait foi.
*/
async function reprendreGammesRecentes(
codeFournisseur: string,
changes: GammesSnapshot,
baseSnapshotId: number | null | undefined,
): Promise<GammesSnapshot> {
const dernier = await lireDernierSnapshot(codeFournisseur);
if (!dernier || dernier.id === baseSnapshotId) return changes;
let base: GammesSnapshot = {};
if (baseSnapshotId != null) {
const [ligne] = await db
.select({ changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(and(eq(sessionSnapshots.id, baseSnapshotId), eq(sessionSnapshots.codeFournisseur, codeFournisseur)))
.limit(1);
base = (ligne?.changes ?? {}) as GammesSnapshot;
}
const fusion = { ...changes };
for (const [codein, delta] of Object.entries(dernier.changes)) {
if (delta?.after && delta.after !== base[codein]?.after) fusion[codein] = delta;
}
return fusion;
}
+1 -12
View File
@@ -22,7 +22,6 @@ 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";
@@ -52,11 +51,6 @@ 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;
/**
@@ -79,7 +73,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
const [network, gammes, aValider] = await Promise.all([
const [network, gammes] = 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));
@@ -92,10 +86,6 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
return new Map<string, string>();
})
: Promise.resolve(new Map<string, string>()),
gammesAValiderParArticle(codeins).catch((e) => {
console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160));
return new Map<string, string>();
}),
]);
return rows.map((row) => {
@@ -107,7 +97,6 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
const enriched: EnrichedProductRow = {
...row,
codeGammeServeur: gammeServeur,
gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null,
network: metrics,
trend: null, // renseignée plus bas, une fois la série mensuelle connue
};
+28 -29
View File
@@ -1,16 +1,14 @@
/**
* CollectFlow — Affectation de gammes via l'API `/api/v1`.
*
* L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
* rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
* modification non enregistrée — on y voit donc quels produits changent de gamme
* avant de valider (« Enregistrer », même chemin que toute modification de la
* Grille) ou de rejeter (« Annuler les modifications »). Tant qu'elle n'est pas
* validée, `codeGamme` reste inchangée et la proposition est exposée dans
* `gammeAValider`.
* L'API suit exactement le chemin de la Grille (`enregistrerGammes`) : la gamme est
* enregistrée dans le snapshot du fournisseur, reportée dans l'instantané
* `grid_rows` et visible immédiatement dans l'application. Comme dans la Grille,
* rien n'est écrit dans la base FF : `codeGammeServeur` reste la gamme d'origine
* jusqu'à l'import des gammes modifiées.
*
* Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
* proposé et la réponse liste les articles en cause.
* enregistré et la réponse liste les articles en cause.
*/
import "server-only";
@@ -19,24 +17,18 @@ import type { NextRequest } from "next/server";
import { fail } from "@/lib/api-response";
import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider";
import { enregistrerGammes, type ChangementGamme } from "@/features/grid/api/enregistrer-gammes";
export interface ResultatGamme {
codein: string;
/** Gamme proposée. */
/** Gamme désormais affectée. */
gamme: string;
/** Gamme enregistrée au moment de l'appel (modifications validées comprises). */
/** Gamme courante avant l'appel (modifications enregistrées comprises). */
gammePrecedente: string | null;
/** Gamme présente en base FF, non modifiée par l'API. */
codeGammeServeur: string | null;
/**
* `true` = la gamme change : proposition déposée, à valider dans la Grille.
* `false` = l'article a déjà cette gamme : rien à valider (une proposition
* antérieure en attente pour lui est retirée).
*/
/** `false` quand l'article avait déjà cette gamme : rien n'a été écrit pour lui. */
modifie: boolean;
/** `a_valider` quand `modifie`, sinon `inchangee`. */
statut: "a_valider" | "inchangee";
}
export interface AffectationGammes {
@@ -55,7 +47,7 @@ export async function lireCorpsJson(req: NextRequest): Promise<unknown | Respons
}
/**
* Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
* Affecte des gammes à des articles d'un fournisseur.
*
* Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la
* demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
@@ -65,7 +57,7 @@ export async function affecterGammes(input: {
codeFournisseur: string;
changes: ReadonlyArray<{ codein: string; gamme: string }>;
compute: boolean;
/** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */
/** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */
auteur: string;
}): Promise<AffectationGammes | Response> {
const { codeFournisseur, compute, auteur } = input;
@@ -95,8 +87,7 @@ export async function affecterGammes(input: {
}
const resultats: ResultatGamme[] = [];
const propositions: PropositionGamme[] = [];
const annules: string[] = [];
const changements: ChangementGamme[] = [];
let nomFournisseur: string | null = null;
for (const [codein, gamme] of demandes) {
@@ -109,19 +100,27 @@ export async function affecterGammes(input: {
gammePrecedente: actuelle.codeGamme,
codeGammeServeur: actuelle.codeGammeInit,
modifie,
statut: modifie ? "a_valider" : "inchangee",
});
if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme });
else annules.push(codein);
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 });
}
}
try {
await proposerGammes({ codeFournisseur, propositions, annules, auteur });
await enregistrerGammes({
codeFournisseur,
nomFournisseur,
magasin: "TOTAL",
changes: changements,
userId: null,
label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`,
});
} catch (e) {
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.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.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`);
console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`);
return { codeFournisseur, nomFournisseur, resultats };
}
-104
View File
@@ -1,104 +0,0 @@
/**
* 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<void> {
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<GammeAValider[]> {
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<Map<string, string>> {
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<void> {
if (codeins.length === 0) return;
await db
.delete(gammesAValider)
.where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins)));
}
+1 -5
View File
@@ -880,8 +880,6 @@ 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;
@@ -931,13 +929,12 @@ 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, af1.reference
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
ORDER BY af1.code
@@ -962,7 +959,6 @@ 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,