Compare commits

...
Author SHA1 Message Date
LogiFlow d2c3dc7f8e Merge pull request #26 from R0m1k3/claude/fervent-cerf-5a027i
Export des gammes découpé en fichiers de 5 000 lignes maximum
2026-10-11 10:01:47 +02:00
Claude 1474c0cbb4 Export des gammes découpé en fichiers de 5 000 lignes maximum
Le logiciel de mise à jour des gammes refuse les fichiers trop gros
(ex. 28 590 lignes). L'export est désormais découpé en plusieurs
fichiers (_partie_1_sur_6, …) à intégrer un par un.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jxxh5X1FHMUPFu2S2JpyT
2026-10-11 07:56:43 +00:00
LogiFlow 60a1d38b6f Merge pull request #25 from R0m1k3/claude/loving-fermi-qdh0qy
Tableaux : largeur du contenu au lieu de s'étirer sur les écrans très larges
2026-10-06 14:07:09 +02:00
Claude 1f329b8595 Tableaux : largeur du contenu au lieu de s'étirer sur les écrans très larges
Sur un écran très large, la colonne de libellé absorbait tout l'espace en
trop : le nom du fournisseur se retrouvait à un millier de pixels de ses
chiffres. Le tableau prend désormais la largeur de son contenu (défilement
au-delà de l'écran) et l'option « grow » devient « label » : largeur
naturelle, retour à la ligne au-delà de 28rem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 10:14:55 +00:00
LogiFlow a252636fc1 Merge pull request #24 from R0m1k3/claude/loving-fermi-qdh0qy
Tableaux plus lisibles : colonne extensible, lignes alternées, groupes par magasin, colonnes figées
2026-10-06 11:49:38 +02:00
Claude 4c67fb7171 Tableaux plus lisibles : colonne extensible, lignes alternées, groupes par magasin, colonnes figées
DataTable gagne quatre options de colonne :
- grow : la désignation prend la largeur restante, les chiffres restent
  groupés au lieu de s'étirer sur un écran large ;
- sticky : Code et Désignation restent visibles au défilement horizontal
  (désignation bornée à 45 % de la largeur sur mobile) ;
- group : en-tête commun par magasin et trait de séparation entre groupes ;
- highlight : fond teinté pour le groupe « Total nos magasins ».
Toutes les lignes alternent désormais de couleur, survol teinté.

Appliqué à Meilleures ventes (Référence déplacée après la Désignation),
Ventes par mois et Publicités.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 09:38:35 +00:00
LogiFlow 4340e2999b Merge pull request #23 from R0m1k3/claude/loving-fermi-qdh0qy
Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes
2026-10-06 11:28:59 +02:00
Claude b373dabeaa 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
2026-10-06 09:27:07 +00:00
LogiFlow efd6c00cf0 Merge pull request #21 from R0m1k3/claude/loving-ride-epppkv
Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
2026-10-05 13:29:13 +02:00
26 changed files with 696 additions and 195 deletions

No files matched your search

+14
View File
@@ -212,6 +212,20 @@ async function main() {
`); `);
console.log("[DB Init] Table sync_fournisseurs is verified/created."); 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(); await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting."); console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0); process.exit(0);
+13 -25
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useTransition, type ReactNode } from "react"; import { useMemo, useTransition } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { Segmented, type TabItem } from "@/components/ui/tabs"; import { Segmented, type TabItem } from "@/components/ui/tabs";
@@ -84,16 +84,6 @@ function Montant({ v }: { v: number }) {
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>; 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({ export function AnalyticsClient({
mode, mode,
mois, mois,
@@ -126,42 +116,40 @@ export function AnalyticsClient({
sortValue: (r) => r.label, sortValue: (r) => r.label,
cell: (r) => <span className="font-medium">{r.label}</span>, cell: (r) => <span className="font-medium">{r.label}</span>,
footer: () => "Total", footer: () => "Total",
className: "min-w-[220px]", className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
label: true,
}, },
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => { ...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
const total = g.id === "total"; const total = g.id === "total";
const bord = "border-l border-[var(--border)]"; // 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 };
return [ return [
{ {
...commun,
id: `${g.id}-ca`, id: `${g.id}-ca`,
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>, header: libelleMois,
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`, hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
align: "right",
sortValue: (r) => r[g.ca], sortValue: (r) => r[g.ca],
cell: (r) => <Montant v={r[g.ca]} />, cell: (r) => <Montant v={r[g.ca]} />,
footer: (rows) => <Montant v={somme(rows, g.ca)} />, footer: (rows) => <Montant v={somme(rows, g.ca)} />,
className: cn(bord, total && "font-semibold"), className: cn(total && "font-semibold"),
headerClassName: bord,
}, },
{ {
...commun,
id: `${g.id}-n1`, id: `${g.id}-n1`,
header: ( header: <span className="inline-flex items-center gap-1">{libelleMoisN1} · <Terme id="n1" /></span>,
<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`, 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], sortValue: (r) => r[g.caN1],
cell: (r) => <Montant v={r[g.caN1]} />, cell: (r) => <Montant v={r[g.caN1]} />,
footer: (rows) => <Montant v={somme(rows, g.caN1)} />, footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
className: cn(total && "font-semibold"), className: cn(total && "font-semibold"),
}, },
{ {
...commun,
id: `${g.id}-evolution`, id: `${g.id}-evolution`,
header: <EnTete groupe={g.titre}>Évolution</EnTete>, header: "Évolution",
hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`, hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`,
align: "right",
sortValue: (r) => evolution(r[g.ca], r[g.caN1]), sortValue: (r) => evolution(r[g.ca], r[g.caN1]),
cell: (r) => <DeltaBadge pct={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))} />, 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 ( return (
<div className="mx-auto w-full max-w-screen-2xl"> <div className="w-full min-w-0">
<PageHeader <PageHeader
icon={CalendarRange} icon={CalendarRange}
title="Ventes par mois" title="Ventes par mois"
+31 -30
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition, type ReactNode } from "react"; import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { Input, Label } from "@/components/ui/form-controls"; import { Input, Label } from "@/components/ui/form-controls";
@@ -68,56 +68,45 @@ 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>[] { function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
const total = g.id === "total"; const total = g.id === "total";
const bord = "border-l border-[var(--border)]"; // 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 };
return [ return [
{ {
...commun,
id: `${g.id}-qte`, id: `${g.id}-qte`,
header: <EnTete groupe={g.titre}>Qté</EnTete>, header: "Qté",
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`, hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.qte], sortValue: r => r[g.qte],
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide), cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
footer: rows => fmtEntier(somme(rows, g.qte)), footer: rows => fmtEntier(somme(rows, g.qte)),
className: cn(bord, total && "font-semibold"), className: cn(total && "font-semibold"),
headerClassName: bord,
}, },
{ {
...commun,
id: `${g.id}-ca`, id: `${g.id}-ca`,
header: <EnTete groupe={g.titre}>CA TTC</EnTete>, header: "CA TTC",
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`, hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.ca], sortValue: r => r[g.ca],
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide), cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
footer: rows => fmtEur2(somme(rows, g.ca)), footer: rows => fmtEur2(somme(rows, g.ca)),
className: cn("whitespace-nowrap", total && "font-semibold"), className: cn(total && "font-semibold"),
}, },
{ {
...commun,
id: `${g.id}-marge`, id: `${g.id}-marge`,
header: <EnTete groupe={g.titre}>% marge</EnTete>, header: "% marge",
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`, 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]), sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
cell: r => <CelluleMarge taux={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))} />, footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
className: "whitespace-nowrap",
}, },
{ {
...commun,
id: `${g.id}-stock`, id: `${g.id}-stock`,
header: <EnTete groupe={g.titre}>Stock</EnTete>, header: "Stock",
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`, hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
align: "right",
sortValue: r => r[g.stock], sortValue: r => r[g.stock],
cell: r => <CelluleStock v={r[g.stock]} />, cell: r => <CelluleStock v={r[g.stock]} />,
footer: rows => fmtEntier(somme(rows, g.stock)), footer: rows => fmtEntier(somme(rows, g.stock)),
@@ -133,6 +122,7 @@ const COLONNES: DataColumn<Ligne>[] = [
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>, cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
footer: () => "Total", footer: () => "Total",
className: "whitespace-nowrap", className: "whitespace-nowrap",
sticky: true,
}, },
{ {
id: "libelle", id: "libelle",
@@ -140,7 +130,17 @@ const COLONNES: DataColumn<Ligne>[] = [
sortValue: r => r.libelle.trim(), sortValue: r => r.libelle.trim(),
cell: r => <span className="font-medium">{r.libelle.trim()}</span>, cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>, footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
className: "min-w-[220px]", 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",
}, },
{ {
id: "fournisseur", id: "fournisseur",
@@ -170,7 +170,7 @@ const FILTRES: DataFilter<Ligne>[] = [
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" }, { id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
]; ];
const recherche = (r: Ligne) => [r.codein, r.libelle]; const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
interface Props { interface Props {
dateDebut: string; dateDebut: string;
@@ -204,21 +204,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
feuille: "Meilleures ventes", feuille: "Meilleures ventes",
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`, fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
entetes: [ entetes: [
"Code", "Désignation", "Fournisseur", "Nomenclature", "Code", "Désignation", "Référence", "Fournisseur", "Nomenclature",
...GROUPES.flatMap(g => [ ...GROUPES.flatMap(g => [
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`, `Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
]), ]),
], ],
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], largeurs: [12, 40, 18, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
lignes: rows.map(r => [ lignes: rows.map(r => [
r.codein, r.codein,
r.libelle.trim(), r.libelle.trim(),
r.reference,
r.fournisseur, r.fournisseur,
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "", 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]]), ...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
]), ]),
totaux: [ totaux: [
`TOTAL — ${rows.length} articles`, "", "", "", `TOTAL — ${rows.length} articles`, "", "", "", "",
...GROUPES.flatMap(g => { ...GROUPES.flatMap(g => {
const ca = somme(rows, g.ca); const ca = somme(rows, g.ca);
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)]; return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
@@ -266,7 +267,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
columns={COLONNES} columns={COLONNES}
rowKey={r => r.codein} rowKey={r => r.codein}
searchIn={recherche} searchIn={recherche}
searchPlaceholder="Code ou désignation…" searchPlaceholder="Code, référence ou désignation…"
filters={FILTRES} filters={FILTRES}
pageSize={100} pageSize={100}
unite="produits" unite="produits"
+4 -1
View File
@@ -13,6 +13,8 @@ export const metadata: Metadata = { title: "Meilleures ventes" };
export interface HitParadePivotRow { export interface HitParadePivotRow {
codein: string; codein: string;
libelle: string; libelle: string;
/** Référence fournisseur de l'article. */
reference: string;
fournisseur: string; fournisseur: string;
nomenclature_code: string; nomenclature_code: string;
nomenclature: string; nomenclature: string;
@@ -38,6 +40,7 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
map.set(row.codein, { map.set(row.codein, {
codein: row.codein, codein: row.codein,
libelle: row.libelle, libelle: row.libelle,
reference: row.reference ?? "",
fournisseur: row.fournisseur, fournisseur: row.fournisseur,
nomenclature_code: row.nomenclature_code, nomenclature_code: row.nomenclature_code,
nomenclature: row.nomenclature, nomenclature: row.nomenclature,
@@ -100,7 +103,7 @@ export default async function HitParadePage(props: {
} }
return ( return (
<div className="mx-auto w-full max-w-screen-2xl"> <div className="w-full min-w-0">
<PageHeader <PageHeader
icon={Trophy} icon={Trophy}
title="Meilleures ventes" title="Meilleures ventes"
+5 -1
View File
@@ -76,7 +76,7 @@ function StatutBadge({ statut }: { statut?: string }) {
/** Intitulé d'abord, code de l'opération en petit dessous. */ /** Intitulé d'abord, code de l'opération en petit dessous. */
function Operation({ intitule, code }: { intitule: string; code: string }) { function Operation({ intitule, code }: { intitule: string; code: string }) {
return ( return (
<div className="min-w-[220px]"> <div className="min-w-[140px] sm:min-w-[220px]">
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div> <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>} {code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
</div> </div>
@@ -171,6 +171,8 @@ export function PublicitesClient({
header: "Opération", header: "Opération",
sortValue: (p) => p.intitule, sortValue: (p) => p.intitule,
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />, cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
sticky: true,
label: true,
footer: (rows) => footer: (rows) =>
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`, `Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
}, },
@@ -261,6 +263,8 @@ export function PublicitesClient({
header: "Opération", header: "Opération",
sortValue: (h) => h.intitule, sortValue: (h) => h.intitule,
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />, 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" : ""}`, 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 ( return (
<div className="mx-auto w-full max-w-screen-2xl"> <div className="w-full min-w-0">
<PageHeader <PageHeader
icon={Megaphone} icon={Megaphone}
title="Publicités" title="Publicités"
+3 -2
View File
@@ -12,8 +12,8 @@ export const maxDuration = 300;
/** /**
* POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] } * POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }
* *
* Affecte ou change la gamme de plusieurs articles d'un fournisseur en un appel — * Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les
* un seul snapshot enregistré, au lieu d'un par article avec /products/:codein/gamme. * propositions restent **à valider** dans la Grille (cf. lib/api-gammes).
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404). * Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -42,6 +42,7 @@ export async function POST(req: NextRequest) {
nomFournisseur: res.nomFournisseur, nomFournisseur: res.nomFournisseur,
demandes: res.resultats.length, demandes: res.resultats.length,
modifies: res.resultats.filter((r) => r.modifie).length, modifies: res.resultats.filter((r) => r.modifie).length,
aValider: res.resultats.filter((r) => r.statut === "a_valider").length,
enregistreLe: new Date().toISOString(), enregistreLe: new Date().toISOString(),
}, },
}); });
+19 -14
View File
@@ -72,9 +72,11 @@ export async function GET(req: NextRequest) {
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée " + "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`codeGammeServeur`).\n\n" + "(`codeGammeServeur`).\n\n"
+ "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) " + "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) "
+ "affectent ou changent une gamme exactement comme la Grille : la nouvelle valeur apparaît " + "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille "
+ "aussitôt dans `codeGamme` et dans l'application, tandis que `codeGammeServeur` reste la gamme " + "comme une modification non enregistrée, pour voir quels produits changent de gamme, et "
+ "en base FF jusqu'à l'import des gammes modifiées.", + "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, "
+ "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, "
+ "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.",
}, },
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }], servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }], security: [{ ApiKeyAuth: [] }],
@@ -151,7 +153,8 @@ export async function GET(req: NextRequest) {
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour " "Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".", + "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
}, },
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." }, codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." },
gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." }, totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" }, totalQuantite: { type: "number" },
totalMarge: { type: "number" }, totalMarge: { type: "number" },
@@ -177,10 +180,11 @@ export async function GET(req: NextRequest) {
type: "object", type: "object",
properties: { properties: {
codein: { type: "string" }, codein: { type: "string" },
gamme: { type: "string", enum: codesGamme, description: "Gamme désormais affectée." }, gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." },
gammePrecedente: { type: ["string", "null"], description: "Gamme courante avant l'appel." }, gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." },
codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." }, codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." },
modifie: { type: "boolean", description: "false = l'article avait déjà cette gamme, rien n'a été écrit." }, modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." },
statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." },
}, },
}, },
Trend: { Trend: {
@@ -390,9 +394,10 @@ export async function GET(req: NextRequest) {
"/products/{codein}/gamme": { "/products/{codein}/gamme": {
put: { put: {
operationId: "changerGammeProduit", operationId: "changerGammeProduit",
summary: "Affecter ou changer la gamme d'un produit", summary: "Proposer la gamme d'un produit (à valider)",
description: description:
"Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". " "Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît "
+ "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". "
+ "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs " + "`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é. " + "(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.", + "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.",
@@ -417,7 +422,7 @@ export async function GET(req: NextRequest) {
}, },
responses: { responses: {
"200": { "200": {
description: "Gamme enregistrée", description: "Gamme proposée, à valider",
content: { content: {
"application/json": { "application/json": {
schema: { schema: {
@@ -444,10 +449,10 @@ export async function GET(req: NextRequest) {
"/gammes": { "/gammes": {
post: { post: {
operationId: "changerGammes", operationId: "changerGammes",
summary: "Affecter ou changer la gamme de plusieurs produits", summary: "Proposer la gamme de plusieurs produits (à valider)",
description: description:
"Enregistre en un appel les gammes de plusieurs articles d'un même fournisseur. " "Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider "
+ "Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est enregistré " + "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. " + "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".", + "Valeurs : " + legendeGammes + ".",
requestBody: { requestBody: {
@@ -480,7 +485,7 @@ export async function GET(req: NextRequest) {
}, },
responses: { responses: {
"200": { "200": {
description: "Gammes enregistrées", description: "Gammes proposées, à valider",
content: { content: {
"application/json": { "application/json": {
schema: { schema: {
@@ -13,8 +13,9 @@ export const maxDuration = 300;
/** /**
* PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" } * PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" }
* *
* Affecte une gamme à un article, ou la change. Même enregistrement que la Grille : * Propose une gamme pour un article. La proposition reste **à valider** : elle
* la nouvelle gamme est visible immédiatement dans l'application et dans `codeGamme`. * apparaît dans la Grille comme une modification non enregistrée, et `codeGamme`
* ne change qu'une fois validée (« Enregistrer » dans la Grille).
* *
* `fournisseur` n'est requis que si l'article est référencé chez plusieurs * `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é. * fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
+32 -1
View File
@@ -578,4 +578,35 @@
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text-primary); color: var(--text-primary);
box-shadow: var(--shadow-sm); 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));
}
+172 -54
View File
@@ -8,7 +8,7 @@
* en réimplémentaient chacune une partie (13 tableaux faits à la main). * en réimplémentaient chacune une partie (13 tableaux faits à la main).
*/ */
import { useDeferredValue, useMemo, useState, type ReactNode } from "react"; import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react"; import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SearchInput, Select } from "@/components/ui/form-controls"; import { SearchInput, Select } from "@/components/ui/form-controls";
@@ -33,6 +33,26 @@ export interface DataColumn<T> {
footer?: (rows: readonly T[]) => ReactNode; footer?: (rows: readonly T[]) => ReactNode;
className?: string; className?: string;
headerClassName?: 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> { export interface DataFilter<T> {
@@ -198,6 +218,130 @@ export function DataTable<T>({
const alignement = (a?: "left" | "right" | "center") => const alignement = (a?: "left" | "right" | "center") =>
a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left"; 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 ( return (
<div className={cn("space-y-3", className)}> <div className={cn("space-y-3", className)}>
{/* Barre d'outils */} {/* Barre d'outils */}
@@ -249,52 +393,22 @@ export function DataTable<T>({
/> />
) : ( ) : (
<div <div
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]" // 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)]"
style={{ maxHeight }} style={{ maxHeight }}
> >
<table className="min-w-full text-sm"> <table ref={tableRef} className="dt-table text-sm">
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]"> <thead className="sticky top-0 z-10">
<tr> {avecGroupes ? (
{columns.map((c) => { <>
const triable = Boolean(c.sortValue); <tr className="bg-[var(--bg-elevated)]">{ligneGroupes}</tr>
const actif = tri?.id === c.id; <tr className="bg-[var(--bg-elevated)]">
const contenu = ( {columns.map((c, i) => (c.group ? enTete(c, i) : null))}
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}> </tr>
{c.header} </>
{triable && ( ) : (
actif <tr className="bg-[var(--bg-elevated)]">{columns.map((c, i) => enTete(c, i))}</tr>
? (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> </thead>
<tbody> <tbody>
{lignesAffichees.map((r, i) => ( {lignesAffichees.map((r, i) => (
@@ -302,39 +416,43 @@ export function DataTable<T>({
key={rowKey(r, i)} key={rowKey(r, i)}
onClick={onRowClick ? () => onRowClick(r) : undefined} onClick={onRowClick ? () => onRowClick(r) : undefined}
className={cn( className={cn(
"border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]", "dt-row border-b border-[var(--border)] last:border-b-0",
onRowClick && "cursor-pointer", onRowClick && "cursor-pointer",
)} )}
> >
{columns.map((c) => ( {columns.map((c, ci) => (
<td <td
key={c.id} key={c.id}
style={styleFigee(ci)}
className={cn( className={cn(
"px-3 py-2 text-[var(--text-primary)]", "px-3 py-2 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums", c.align === "right" && "text-right tabular-nums whitespace-nowrap",
c.align === "center" && "text-center", c.align === "center" && "text-center",
classesColonne(c, ci),
c.className, c.className,
)} )}
> >
{c.cell(r)} {borne(c, ci, c.cell(r))}
</td> </td>
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
{showFooter && ( {showFooter && (
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold"> <tfoot className="sticky bottom-0 z-10 font-semibold">
<tr> <tr className="bg-[var(--bg-elevated)]">
{columns.map((c) => ( {columns.map((c, ci) => (
<td <td
key={c.id} key={c.id}
style={styleFigee(ci)}
className={cn( className={cn(
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]", "border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums", c.align === "right" && "text-right tabular-nums whitespace-nowrap",
c.align === "center" && "text-center", c.align === "center" && "text-center",
classesColonne(c, ci),
)} )}
> >
{c.footer ? c.footer(triees) : null} {c.footer ? borne(c, ci, c.footer(triees)) : null}
</td> </td>
))} ))}
</tr> </tr>
+24 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core"; import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", { export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
@@ -283,3 +283,26 @@ export const aiSupplierContext = pgTable("ai_supplier_context", {
/** When it was last updated */ /** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(), 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: "/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/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" }, { 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: "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: "Affecter ou changer la gamme de plusieurs produits d'un fournisseur en un appel" }, { 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: "/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)" }, { 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 { z } from "zod";
import { verifierSession } from "@/lib/authz"; import { verifierSession } from "@/lib/authz";
import { enregistrerGammes } from "./enregistrer-gammes"; import { enregistrerGammes } from "./enregistrer-gammes";
import { supprimerGammesAValider } from "@/lib/gammes-a-valider";
const SaveDraftsSchema = z.object({ const SaveDraftsSchema = z.object({
codeFournisseur: z.string(), codeFournisseur: z.string(),
@@ -15,6 +16,11 @@ const SaveDraftsSchema = z.object({
codeGamme: z.string(), 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( export async function saveDraftChanges(
@@ -28,7 +34,7 @@ export async function saveDraftChanges(
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message }; 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 rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null; const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -36,6 +42,7 @@ export async function saveDraftChanges(
try { try {
const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId }); const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId });
await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees);
return { success: true, saved }; return { success: true, saved };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error"; 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 { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; 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 { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import { import {
DropdownMenu, DropdownMenu,
@@ -26,6 +27,7 @@ import {
exporterGammeA, exporterGammeA,
exporterTousProduits, exporterTousProduits,
imprimerPdf, imprimerPdf,
LIGNES_MAX_PAR_FICHIER_GAMMES,
lignesModifiees, lignesModifiees,
} from "@/features/grid/lib/exports"; } from "@/features/grid/lib/exports";
@@ -41,6 +43,14 @@ 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 { function messageErreur(err: unknown): string {
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE; if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
@@ -62,7 +72,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null); 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 supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => { const lastQlikUpdate = useMemo(() => {
@@ -77,7 +87,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
startTransition(async () => { startTransition(async () => {
try { try {
const result = await save(); 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" : ""}.`); toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
} else { } else {
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`); toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
@@ -89,16 +101,36 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}; };
const handleReset = async () => { 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({ const ok = await confirmer({
titre: "Annuler les modifications ?", 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", libelleConfirmer: "Annuler les modifications",
danger: true, danger: true,
}); });
if (ok) { if (!ok) return;
resetDrafts(); if (nbAValider > 0) {
toast.info("Modifications annulées."); 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.");
}; };
/** /**
@@ -194,7 +226,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const nom = lignesModifiees()[0].nomFournisseur; const nom = lignesModifiees()[0].nomFournisseur;
// Copie dans l'Historique, onglet « Exports » // Copie dans l'Historique, onglet « Exports »
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export"); await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
return exporterFichierGammes(true); return exporterFichierGammes(true).then(signalerDecoupage);
}, "Aucune gamme n'a été modifiée par rapport à FF.")} }, "Aucune gamme n'a été modifiée par rapport à FF.")}
> >
<FileSpreadsheet /> <FileSpreadsheet />
@@ -205,7 +237,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")} onSelect={() => lancer("complet", () => exporterFichierGammes(false).then(signalerDecoupage), "Aucun produit à exporter.")}
> >
<FileSpreadsheet /> <FileSpreadsheet />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -256,6 +288,14 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 && ( {hasDrafts && (
<Button variant="ghost" onClick={handleReset} disabled={isPending}> <Button variant="ghost" onClick={handleReset} disabled={isPending}>
<RotateCcw /> Annuler <RotateCcw /> Annuler
@@ -265,9 +305,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
{isPending ? <Loader2 className="animate-spin" /> : <Save />} {isPending ? <Loader2 className="animate-spin" /> : <Save />}
{isPending {isPending
? "Enregistrement…" ? "Enregistrement…"
: hasDrafts : count > 0
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}` ? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
: "Aucune modification"} : hasDrafts
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
: "Aucune modification"}
</Button> </Button>
</div> </div>
</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 { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { useStorePatch } from "@/features/grid/hooks/use-store-patch"; 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 type { ProductRow } from "@/types/grid";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react"; import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -231,6 +232,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return () => controller.abort(); return () => controller.abort();
}, [codeFournisseur, refreshRequest, setRows, isMounted]); }, [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. // Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
useStorePatch(codeFournisseur, isMounted); 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 isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined);
const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme); const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme);
const setDraftGamme = useGridStore((s) => s.setDraftGamme); 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 isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null;
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme; const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
@@ -267,6 +270,14 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
isDraft={isDraft} isDraft={isDraft}
onChange={(g: GammeCode) => setDraftGamme(codein, g)} 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 && ( {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é"> <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" /> <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++; for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return 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 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); const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait // Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur. // (modifications × lignes) comparaisons sur un gros fournisseur.
@@ -37,15 +43,18 @@ export function useSaveDrafts(magasin: string) {
codeGamme: codeGamme as GammeCode, 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 // 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 codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? ""; const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes }); const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees });
if (result.success) { if (result.success) {
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les // Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
@@ -53,9 +62,10 @@ export function useSaveDrafts(magasin: string) {
// codeGammeInit) reste juste. // codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts); applyDraftsToRows(activeDrafts);
clearDrafts(Object.keys(activeDrafts)); clearDrafts(Object.keys(activeDrafts));
retirerGammesAValider(gammesAValiderTraitees);
} }
return result; return result;
}, [magasin]); }, [magasin]);
return { save, hasDrafts: count > 0, count }; return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider };
} }
+33 -14
View File
@@ -32,14 +32,24 @@ export function lignesModifiees(): ProductRow[] {
} }
/** /**
* Fichier d'import des gammes (CODE FOURNISSEUR, GENCOD, GAMME), généré par le * Nombre maximal de produits par fichier d'import des gammes. Le logiciel de
* serveur. `seulementModifiees` : uniquement les gammes changées. * mise à jour des gammes refuse les fichiers trop gros (~28 000 lignes) : au-delà,
* Renvoie `false` s'il n'y a rien à exporter. * l'export est découpé en plusieurs fichiers à intégrer un par un.
*/ */
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<boolean> { 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
* 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).
*/
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<number> {
const { rows, draftChanges } = useGridStore.getState(); const { rows, draftChanges } = useGridStore.getState();
const lignes = seulementModifiees ? lignesModifiees() : rows; const lignes = seulementModifiees ? lignesModifiees() : rows;
if (lignes.length === 0) return false; if (lignes.length === 0) return 0;
const nomFournisseur = lignes[0].nomFournisseur; const nomFournisseur = lignes[0].nomFournisseur;
const changes = lignes.map((r) => ({ const changes = lignes.map((r) => ({
@@ -49,16 +59,25 @@ export async function exporterFichierGammes(seulementModifiees: boolean): Promis
gamme: gammeEffective(r, draftChanges) as string, 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 prefixe = seulementModifiees ? "Modifications_Gammes" : "Export_Complet";
telecharger(await res.blob(), `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}.xlsx`); const base = `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}`;
return true; 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;
} }
/** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */ /** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */
+45
View File
@@ -15,6 +15,14 @@ export interface RowsMeta {
patchedStores: string[]; 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 »). */ /** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`; export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
@@ -25,6 +33,20 @@ interface GridState {
rowsByCodein: Record<string, ProductRow>; rowsByCodein: Record<string, ProductRow>;
/** Draft edits: codein → new GammeCode */ /** Draft edits: codein → new GammeCode */
draftChanges: Record<string, 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; filters: GridFilters;
summary: GridSummary; summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable"; displayDensity: "compact" | "normal" | "comfortable";
@@ -141,6 +163,7 @@ export const useGridStore = create<GridState>()(
rowsByCodein: {}, rowsByCodein: {},
activeMagasin: "TOTAL", activeMagasin: "TOTAL",
draftChanges: {}, draftChanges: {},
gammesAValider: {},
filters: { filters: {
magasin: null, magasin: null,
codeFournisseur: null, codeFournisseur: null,
@@ -211,6 +234,28 @@ export const useGridStore = create<GridState>()(
}, 80); }, 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: () => { resetDrafts: () => {
set({ draftChanges: {} }); set({ draftChanges: {} });
if (summaryTimer) clearTimeout(summaryTimer); if (summaryTimer) clearTimeout(summaryTimer);
+12 -1
View File
@@ -22,6 +22,7 @@ import "server-only";
import type { ProductRow } from "@/types/grid"; import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client"; 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 // Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait. // l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend"; import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
@@ -51,6 +52,11 @@ export interface EnrichedProductRow extends ProductRow {
* `null` si l'article n'a aucune gamme en base. * `null` si l'article n'a aucune gamme en base.
*/ */
codeGammeServeur: string | null; 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. */ /** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
network: NetworkMetricCached | null; network: NetworkMetricCached | null;
/** /**
@@ -73,7 +79,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 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 codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
const [network, gammes] = await Promise.all([ const [network, gammes, aValider] = await Promise.all([
codesCentraux.length codesCentraux.length
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => { ? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160)); console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
@@ -86,6 +92,10 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
return new Map<string, string>(); return new Map<string, string>();
}) })
: Promise.resolve(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) => { return rows.map((row) => {
@@ -97,6 +107,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
const enriched: EnrichedProductRow = { const enriched: EnrichedProductRow = {
...row, ...row,
codeGammeServeur: gammeServeur, codeGammeServeur: gammeServeur,
gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null,
network: metrics, network: metrics,
trend: null, // renseignée plus bas, une fois la série mensuelle connue trend: null, // renseignée plus bas, une fois la série mensuelle connue
}; };
+29 -28
View File
@@ -1,14 +1,16 @@
/** /**
* CollectFlow — Affectation de gammes via l'API `/api/v1`. * CollectFlow — Affectation de gammes via l'API `/api/v1`.
* *
* L'API suit exactement le chemin de la Grille (`enregistrerGammes`) : la gamme est * L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
* enregistrée dans le snapshot du fournisseur, reportée dans l'instantané * rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
* `grid_rows` et visible immédiatement dans l'application. Comme dans la Grille, * modification non enregistrée — on y voit donc quels produits changent de gamme
* rien n'est écrit dans la base FF : `codeGammeServeur` reste la gamme d'origine * avant de valider (« Enregistrer », même chemin que toute modification de la
* jusqu'à l'import des gammes modifiées. * 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`.
* *
* Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est * Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
* enregistré et la réponse liste les articles en cause. * proposé et la réponse liste les articles en cause.
*/ */
import "server-only"; import "server-only";
@@ -17,18 +19,24 @@ import type { NextRequest } from "next/server";
import { fail } from "@/lib/api-response"; import { fail } from "@/lib/api-response";
import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store"; import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store";
import { getProductRows } from "@/features/grid/api/get-product-rows"; import { getProductRows } from "@/features/grid/api/get-product-rows";
import { enregistrerGammes, type ChangementGamme } from "@/features/grid/api/enregistrer-gammes"; import { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider";
export interface ResultatGamme { export interface ResultatGamme {
codein: string; codein: string;
/** Gamme désormais affectée. */ /** Gamme proposée. */
gamme: string; gamme: string;
/** Gamme courante avant l'appel (modifications enregistrées comprises). */ /** Gamme enregistrée au moment de l'appel (modifications validées comprises). */
gammePrecedente: string | null; gammePrecedente: string | null;
/** Gamme présente en base FF, non modifiée par l'API. */ /** Gamme présente en base FF, non modifiée par l'API. */
codeGammeServeur: string | null; codeGammeServeur: string | null;
/** `false` quand l'article avait déjà cette gamme : rien n'a été écrit pour lui. */ /**
* `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).
*/
modifie: boolean; modifie: boolean;
/** `a_valider` quand `modifie`, sinon `inchangee`. */
statut: "a_valider" | "inchangee";
} }
export interface AffectationGammes { export interface AffectationGammes {
@@ -47,7 +55,7 @@ export async function lireCorpsJson(req: NextRequest): Promise<unknown | Respons
} }
/** /**
* Affecte des gammes à des articles d'un fournisseur. * Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
* *
* Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la * 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 * demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
@@ -57,7 +65,7 @@ export async function affecterGammes(input: {
codeFournisseur: string; codeFournisseur: string;
changes: ReadonlyArray<{ codein: string; gamme: string }>; changes: ReadonlyArray<{ codein: string; gamme: string }>;
compute: boolean; compute: boolean;
/** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */ /** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */
auteur: string; auteur: string;
}): Promise<AffectationGammes | Response> { }): Promise<AffectationGammes | Response> {
const { codeFournisseur, compute, auteur } = input; const { codeFournisseur, compute, auteur } = input;
@@ -87,7 +95,8 @@ export async function affecterGammes(input: {
} }
const resultats: ResultatGamme[] = []; const resultats: ResultatGamme[] = [];
const changements: ChangementGamme[] = []; const propositions: PropositionGamme[] = [];
const annules: string[] = [];
let nomFournisseur: string | null = null; let nomFournisseur: string | null = null;
for (const [codein, gamme] of demandes) { for (const [codein, gamme] of demandes) {
@@ -100,27 +109,19 @@ export async function affecterGammes(input: {
gammePrecedente: actuelle.codeGamme, gammePrecedente: actuelle.codeGamme,
codeGammeServeur: actuelle.codeGammeInit, codeGammeServeur: actuelle.codeGammeInit,
modifie, modifie,
statut: modifie ? "a_valider" : "inchangee",
}); });
if (modifie) { if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme });
// Même « avant » que la Grille : l'état serveur, à défaut la gamme courante. else annules.push(codein);
changements.push({ codein, codeGammeBefore: actuelle.codeGammeInit ?? actuelle.codeGamme, codeGamme: gamme });
}
} }
try { try {
await enregistrerGammes({ await proposerGammes({ codeFournisseur, propositions, annules, auteur });
codeFournisseur,
nomFournisseur,
magasin: "TOTAL",
changes: changements,
userId: null,
label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`,
});
} catch (e) { } catch (e) {
console.error(`[api/v1/gammes] enregistrement KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(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 a échoué."); return fail("internal_error", "L'enregistrement des gammes à valider a échoué.");
} }
console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`); console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`);
return { codeFournisseur, nomFournisseur, resultats }; return { codeFournisseur, nomFournisseur, resultats };
} }
+104
View File
@@ -0,0 +1,104 @@
/**
* CollectFlow — Gammes proposées par l'API, en attente de validation.
*
* L'API `/api/v1` ne change plus une gamme directement : elle dépose une
* proposition, que la Grille affiche comme une modification non enregistrée.
* L'utilisateur voit ainsi quels produits changent de gamme, puis valide
* (« Enregistrer ») ou rejette (« Annuler les modifications »).
*/
import "server-only";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "@/db";
import { gammesAValider } from "@/db/schema";
export interface PropositionGamme {
codein: string;
/** Gamme proposée. */
gamme: string;
/** Gamme enregistrée au moment de la proposition. */
gammePrecedente: string | null;
}
export interface GammeAValider extends PropositionGamme {
auteur: string | null;
/** Date ISO de la proposition. */
proposeeLe: string;
}
/**
* Dépose des propositions (la dernière l'emporte pour un même article) et retire
* celles des articles `annules` — redemander la gamme déjà enregistrée annule la
* proposition en attente.
*/
export async function proposerGammes(input: {
codeFournisseur: string;
propositions: PropositionGamme[];
annules: string[];
auteur: string;
}): Promise<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)));
}
+5 -1
View File
@@ -880,6 +880,8 @@ export async function pgGetCaByNomenclature(
export interface HitParadeRow { export interface HitParadeRow {
codein: string; codein: string;
libelle: string; libelle: string;
/** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */
reference: string;
fournisseur: string; fournisseur: string;
nomenclature_code: string; nomenclature_code: string;
nomenclature: string; nomenclature: string;
@@ -929,12 +931,13 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
SELECT DISTINCT ON (TRIM(a.codein::text)) SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein, TRIM(a.codein::text) AS codein,
a.libelle1::text AS libelle, a.libelle1::text AS libelle,
COALESCE(TRIM(af.reference::text), '') AS reference,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(n.code, '')::text AS nomenclature_code, COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
FROM articles a FROM articles a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT af1.code SELECT af1.code, af1.reference
FROM artfou1 af1 FROM artfou1 af1
WHERE af1.art_no_id = a.no_id AND af1.preference = 1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1
ORDER BY af1.code ORDER BY af1.code
@@ -959,6 +962,7 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
SELECT SELECT
v.codein, v.codein,
COALESCE(ar.libelle, '') AS libelle, COALESCE(ar.libelle, '') AS libelle,
COALESCE(ar.reference, '') AS reference,
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
COALESCE(ar.nomenclature_code, '') AS nomenclature_code, COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,