Compare commits

...
Author SHA1 Message Date
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
Claude 3519597fb7 Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il
ouvre une modale qui regroupe les produits affichés par nomenclature (code3)
et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos
magasins, le CA réseau, le poids de la nomenclature chez nous et dans le
réseau (écart en points) et l'indice de chaque magasin face au magasin moyen
du réseau. Tri par colonne, filtre texte, ligne de total et export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvNrtCGyjYgPHFoiUkJzhM
2026-10-05 10:12:49 +00:00
LogiFlow 3a0e3bad50 Merge pull request #20 from R0m1k3/claude/eloquent-cannon-2mm04o
API : affecter ou changer la gamme d'un produit
2026-10-05 10:27:23 +02:00
Claude 9448e68509 API : affecter ou changer la gamme d'un produit
Deux endpoints d'écriture dans /api/v1 :
- PUT /products/{codein}/gamme { gamme, fournisseur? } pour un article ;
- POST /gammes { fournisseur, changes[] } pour plusieurs articles d'un
  fournisseur, en tout ou rien.

Ils passent par le même enregistrement que la Grille (extrait de
saveDraftChanges dans enregistrerGammes) : snapshot du fournisseur, cache
de la Grille et instantané grid_rows. Gamme validée contre la liste des
gammes (A, B, C, Y, Z) ; un article déjà dans la gamme demandée n'est pas
réécrit. Spec OpenAPI et page de connexion à l'API mises à jour ; la
légende des gammes de la spec, qui citait une gamme D inexistante, suit
désormais lib/gammes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 08:19:00 +00:00
LogiFlow 540ff7cf29 Merge pull request #19 from R0m1k3/claude/eloquent-cannon-2mm04o
Afficher les montants au centime dans toute l'application
2026-10-05 09:52:04 +02:00
Claude 4a11bdc3b0 Afficher les montants au centime dans toute l'application
Les CA, marges et valeurs de stock étaient arrondis à l'euro (fmtEur0,
fmtEntier suivi de « € », Math.round dans les exports). Tous les montants
sont désormais affichés avec deux décimales : accueil, grille, fiche
produit, hit-parade, publicités, analytics, snapshots, export PDF et
export Excel des manques d'assortiment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 07:50:59 +00:00
37 changed files with 1798 additions and 257 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.");
// 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);
+15 -27
View File
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useTransition, type ReactNode } from "react";
import { useMemo, useTransition } from "react";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { Segmented, type TabItem } from "@/components/ui/tabs";
@@ -9,7 +9,7 @@ import { Select } from "@/components/ui/form-controls";
import { DeltaBadge } from "@/components/ui/badge";
import { Terme } from "@/components/ui/tooltip";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtEur0 } from "@/lib/format";
import { fmtEur2 } from "@/lib/format";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
@@ -81,17 +81,7 @@ const evolution = (ca: number, caN1: number) => (caN1 > 0 ? ((ca - caN1) / caN1)
const recherche = (r: AnalyticsRow) => [r.label, r.key];
function Montant({ v }: { v: number }) {
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur0(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>
);
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>;
}
export function AnalyticsClient({
@@ -126,42 +116,40 @@ export function AnalyticsClient({
sortValue: (r) => r.label,
cell: (r) => <span className="font-medium">{r.label}</span>,
footer: () => "Total",
className: "min-w-[220px]",
className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
label: true,
},
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
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 [
{
...commun,
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`,
align: "right",
sortValue: (r) => r[g.ca],
cell: (r) => <Montant v={r[g.ca]} />,
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
className: cn(total && "font-semibold"),
},
{
...commun,
id: `${g.id}-n1`,
header: (
<EnTete groupe={g.titre}>
{libelleMoisN1} · <Terme id="n1" />
</EnTete>
),
header: <span className="inline-flex items-center gap-1">{libelleMoisN1} · <Terme id="n1" /></span>,
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: <EnTete groupe={g.titre}>Évolution</EnTete>,
header: "Évolution",
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="mx-auto w-full max-w-screen-2xl">
<div className="w-full min-w-0">
<PageHeader
icon={CalendarRange}
title="Ventes par mois"
+8 -8
View File
@@ -10,7 +10,7 @@ import { Badge, DeltaBadge } from "@/components/ui/badge";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
export const metadata: Metadata = { title: "Accueil" };
@@ -76,8 +76,8 @@ function CarteMagasin({ site }: { site: DashboardSiteStats }) {
<div className="grid gap-3 p-4 sm:grid-cols-3">
<Indicateur
label="Chiffre d'affaires"
valeur={fmtEur0(site.ca_hier)}
valeurN1={site.ca_n1 > 0 ? fmtEur0(site.ca_n1) : null}
valeur={fmtEur2(site.ca_hier)}
valeurN1={site.ca_n1 > 0 ? fmtEur2(site.ca_n1) : null}
delta={evolution(site.ca_hier, site.ca_n1)}
/>
<Indicateur
@@ -107,8 +107,8 @@ const CRITERES: Record<Critere, { titre: string; icone: typeof Euro }> = {
function valeurPrincipale(item: DashboardTopItem, critere: Critere): string {
if (critere === "qte") return `${fmtEntier(item.qte)} vendus`;
if (critere === "marge") return fmtEur0(item.marge);
return fmtEur0(item.ca);
if (critere === "marge") return fmtEur2(item.marge);
return fmtEur2(item.ca);
}
function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Critere }) {
@@ -150,7 +150,7 @@ function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Cr
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--text-muted)]">
<span className="truncate">{item.fournisseur}</span>
{critere !== "ca" && <span>CA {fmtEur0(item.ca)}</span>}
{critere !== "ca" && <span>CA {fmtEur2(item.ca)}</span>}
{critere !== "qte" && <span>{fmtEntier(item.qte)} vendus</span>}
<span
style={{ color: item.stock <= 0 ? "var(--accent-error)" : undefined }}
@@ -259,9 +259,9 @@ export default async function DashboardPage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
label="Chiffre d'affaires"
value={fmtEur0(totalCa)}
value={fmtEur2(totalCa)}
delta={evolution(totalCa, totalCaN1)}
hint={totalCaN1 > 0 ? `N-1 : ${fmtEur0(totalCaN1)}` : undefined}
hint={totalCaN1 > 0 ? `N-1 : ${fmtEur2(totalCaN1)}` : undefined}
icon={Euro}
/>
<StatCard
+34 -33
View File
@@ -1,13 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition, type ReactNode } from "react";
import { useState, useTransition } 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";
import { Button } from "@/components/ui/button";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
@@ -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>[] {
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 [
{
...commun,
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`,
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(bord, total && "font-semibold"),
headerClassName: bord,
className: cn(total && "font-semibold"),
},
{
...commun,
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`,
align: "right",
sortValue: r => r[g.ca],
cell: r => (r[g.ca] !== 0 ? fmtEur0(r[g.ca]) : vide),
footer: rows => fmtEur0(somme(rows, g.ca)),
className: cn("whitespace-nowrap", total && "font-semibold"),
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
footer: rows => fmtEur2(somme(rows, g.ca)),
className: cn(total && "font-semibold"),
},
{
...commun,
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`,
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: <EnTete groupe={g.titre}>Stock</EnTete>,
header: "Stock",
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)),
@@ -133,6 +122,7 @@ 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",
@@ -140,7 +130,17 @@ 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-[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",
@@ -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.libelle];
const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
interface Props {
dateDebut: string;
@@ -204,21 +204,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
feuille: "Meilleures ventes",
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
entetes: [
"Code", "Désignation", "Fournisseur", "Nomenclature",
"Code", "Désignation", "Référence", "Fournisseur", "Nomenclature",
...GROUPES.flatMap(g => [
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
]),
],
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
largeurs: [12, 40, 18, 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)];
@@ -266,7 +267,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
columns={COLONNES}
rowKey={r => r.codein}
searchIn={recherche}
searchPlaceholder="Code ou désignation…"
searchPlaceholder="Code, référence ou désignation…"
filters={FILTRES}
pageSize={100}
unite="produits"
+4 -1
View File
@@ -13,6 +13,8 @@ export const metadata: Metadata = { title: "Meilleures ventes" };
export interface HitParadePivotRow {
codein: string;
libelle: string;
/** Référence fournisseur de l'article. */
reference: string;
fournisseur: string;
nomenclature_code: string;
nomenclature: string;
@@ -38,6 +40,7 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
map.set(row.codein, {
codein: row.codein,
libelle: row.libelle,
reference: row.reference ?? "",
fournisseur: row.fournisseur,
nomenclature_code: row.nomenclature_code,
nomenclature: row.nomenclature,
@@ -100,7 +103,7 @@ export default async function HitParadePage(props: {
}
return (
<div className="mx-auto w-full max-w-screen-2xl">
<div className="w-full min-w-0">
<PageHeader
icon={Trophy}
title="Meilleures ventes"
+10 -10
View File
@@ -26,7 +26,7 @@ import { EmptyState } from "@/components/ui/states";
import { Terme, Tooltip } from "@/components/ui/tooltip";
import { toast } from "@/components/ui/feedback";
import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtDecimal2, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { cn } from "@/lib/utils";
import { OpportunitesFamille } from "./opportunites";
@@ -129,7 +129,7 @@ const LIGNES_MENSUELLES_RESEAU: Array<{
{ label: "Quantité vendue", get: (v) => v.qte, fmt: fmtEntier },
{ label: "Magasins vendeurs", get: (v) => v.nbMag, fmt: fmtEntier },
{ label: "Quantité par magasin", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: fmtDecimal1 },
{ label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtEntier },
{ label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtDecimal2 },
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: fmtEur2 },
{ label: "Taux de marge", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDecimal1(n)} %` },
];
@@ -327,8 +327,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
hint="chiffre d'affaires ÷ quantité"
/>
<StatCard label="Chiffre d'affaires" value={fmtEur0(reseau.caReseau)} hint="de tout le réseau" />
<StatCard label={<Terme id="caParMagasin" />} value={fmtEur0(reseau.caParMagasinReseau)} />
<StatCard label="Chiffre d'affaires" value={fmtEur2(reseau.caReseau)} hint="de tout le réseau" />
<StatCard label={<Terme id="caParMagasin" />} value={fmtEur2(reseau.caParMagasinReseau)} />
<StatCard
label={<Terme id="marge">Taux de marge</Terme>}
value={
@@ -556,7 +556,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Field label="Stock disponible"><span className="tabular-nums">{fmtEntier(s.stockdispo)}</span></Field>
<Field label="Valeur du stock"><span className="tabular-nums">{fmtEur0(s.valstock)}</span></Field>
<Field label="Valeur du stock"><span className="tabular-nums">{fmtEur2(s.valstock)}</span></Field>
<Field label={<Terme id="prmp" />}><span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></Field>
<Field label="Dernière vente">{formatDate(s.dernierevente)}</Field>
<Field label="Dernière réception">{formatDate(s.dernierereception)}</Field>
@@ -587,8 +587,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
>
<div className="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Quantité vendue" value={fmtEntier(totauxAffiches.qte)} />
<StatCard label="Chiffre d'affaires TTC" value={fmtEur0(totauxAffiches.ca)} />
<StatCard label={<Terme id="marge" />} value={fmtEur0(totauxAffiches.marge)} />
<StatCard label="Chiffre d'affaires TTC" value={fmtEur2(totauxAffiches.ca)} />
<StatCard label={<Terme id="marge" />} value={fmtEur2(totauxAffiches.marge)} />
<StatCard
label="Taux de marge"
value={
@@ -626,7 +626,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
<th scope="row" className={TH_LIGNE}>Chiffre d&apos;affaires TTC (€)</th>
{months.map((m) => (
<td key={m} className={TD}>
{serie[m]?.ca ? fmtEntier(serie[m].ca) : <span className="text-[var(--text-muted)]">—</span>}
{serie[m]?.ca ? fmtDecimal2(serie[m].ca) : <span className="text-[var(--text-muted)]">—</span>}
</td>
))}
</tr>
@@ -690,8 +690,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<Comparaison
titre={<Terme id="caParMagasin">Chiffre d&apos;affaires par magasin</Terme>}
nous={fmtEur0(comparatif.caLocalParMagasin)}
reseau={comparatif.caReseauParMagasin != null ? fmtEur0(comparatif.caReseauParMagasin) : "—"}
nous={fmtEur2(comparatif.caLocalParMagasin)}
reseau={comparatif.caReseauParMagasin != null ? fmtEur2(comparatif.caReseauParMagasin) : "—"}
/>
<Comparaison
titre={<Terme id="marge">Taux de marge</Terme>}
+2 -2
View File
@@ -12,7 +12,7 @@ import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { GLOSSAIRE } from "@/lib/glossaire";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
/** Intervalle d'interrogation de l'état du job de recherche. */
@@ -401,7 +401,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri
hint: GLOSSAIRE.caParMagasin.definition,
align: "right",
sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null),
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur0(r.caParMagasinReseau) : "—"),
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur2(r.caParMagasinReseau) : "—"),
},
{
id: "marge",
+10 -6
View File
@@ -12,7 +12,7 @@ import { Pagination } from "@/components/ui/pagination";
import { ErrorState } from "@/components/ui/states";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { Terme, InfoBulle } from "@/components/ui/tooltip";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { nomMagasin } from "@/lib/magasins";
import { cn } from "@/lib/utils";
@@ -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-[220px]">
<div className="min-w-[140px] sm: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,6 +171,8 @@ 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" : ""}`,
},
@@ -205,8 +207,8 @@ export function PublicitesClient({
header: <InfoBulle explication={EXPLICATION_CA_PUB}>CA pub période</InfoBulle>,
align: "right",
sortValue: (p) => nombre(p.ca_pub_periode_pub),
cell: (p) => <span className="font-semibold">{fmtEur0(nombre(p.ca_pub_periode_pub))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)),
cell: (p) => <span className="font-semibold">{fmtEur2(nombre(p.ca_pub_periode_pub))}</span>,
footer: (rows) => fmtEur2(somme(rows, (r) => r.ca_pub_periode_pub)),
},
{
id: "partCa",
@@ -261,6 +263,8 @@ 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" : ""}`,
},
{
@@ -297,8 +301,8 @@ export function PublicitesClient({
hint: "Chiffre d'affaires de l'opération, tous magasins confondus.",
align: "right",
sortValue: (h) => nombre(h.ca_total),
cell: (h) => <span className="font-semibold">{fmtEur0(nombre(h.ca_total))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_total)),
cell: (h) => <span className="font-semibold">{fmtEur2(nombre(h.ca_total))}</span>,
footer: (rows) => fmtEur2(somme(rows, (r) => r.ca_total)),
},
{
id: "quantite",
+1 -1
View File
@@ -120,7 +120,7 @@ export default async function PublicitesPage(props: {
]);
return (
<div className="mx-auto w-full max-w-screen-2xl">
<div className="w-full min-w-0">
<PageHeader
icon={Megaphone}
title="Publicités"
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { gammesBatchBodySchema } from "@/lib/api-schemas";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
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).
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
*/
export async function POST(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = gammesBatchBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { fournisseur, changes, compute } = parsed.data;
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes,
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(res.resultats, {
meta: {
codeFournisseur: res.codeFournisseur,
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(),
},
});
}
+144 -6
View File
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
import { GAMMES } from "@/lib/gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -46,13 +47,20 @@ export async function GET(req: NextRequest) {
},
];
const codesGamme = GAMMES.map((g) => g.code);
const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", ");
const erreur = (description: string) => ({
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
});
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
"Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
@@ -62,7 +70,13 @@ export async function GET(req: NextRequest) {
+ "alimenté par une synchronisation séparée.\n\n"
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`codeGammeServeur`).",
+ "(`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.",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
@@ -137,10 +151,10 @@ export async function GET(req: NextRequest) {
type: ["string", "null"],
description:
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
+ "C (performance), D (saisonnier/niche), Z (sortie).",
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." },
gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
@@ -162,6 +176,17 @@ export async function GET(req: NextRequest) {
trend: { $ref: "#/components/schemas/Trend" },
},
},
ResultatGamme: {
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." },
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." },
},
},
Trend: {
type: ["object", "null"],
description:
@@ -266,7 +291,7 @@ export async function GET(req: NextRequest) {
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: `Filtre sur la gamme (${codesGamme.join(", ")}).` },
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
@@ -366,6 +391,119 @@ export async function GET(req: NextRequest) {
},
},
},
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
summary: "Proposer la gamme d'un produit (à valider)",
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 + ". "
+ "`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.",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["gamme"],
properties: {
gamme: { type: "string", enum: codesGamme },
fournisseur: { type: "string", description: "Code fournisseur de l'article." },
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gamme proposée, à valider",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
allOf: [
{ $ref: "#/components/schemas/ResultatGamme" },
{ type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } },
],
},
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Article introuvable chez ce fournisseur"),
},
},
},
"/gammes": {
post: {
operationId: "changerGammes",
summary: "Proposer la gamme de plusieurs produits (à valider)",
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é "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["fournisseur", "changes"],
properties: {
fournisseur: { type: "string", description: "Code fournisseur." },
changes: {
type: "array",
minItems: 1,
maxItems: 10000,
items: {
type: "object",
required: ["codein", "gamme"],
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme },
},
},
},
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gammes proposées, à valider",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } },
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Corps de requête invalide"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"),
},
},
},
"/network/{codeCentrale}": {
get: {
operationId: "obtenirMetriquesReseau",
@@ -0,0 +1,72 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productGammeBodySchema } from "@/lib/api-schemas";
import { listGridSuppliersForCodein } from "@/lib/grid-store";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
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).
*
* `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é.
*/
export async function PUT(
req: NextRequest,
ctx: { params: Promise<{ codein: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codein } = await ctx.params;
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
const corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = productGammeBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { gamme, compute } = parsed.data;
let fournisseur = parsed.data.fournisseur;
if (!fournisseur) {
const fournisseurs = await listGridSuppliersForCodein(codein);
if (fournisseurs.length === 0) {
return fail(
"not_found",
`Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`,
);
}
if (fournisseurs.length > 1) {
return fail(
"bad_request",
`Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`,
{ fournisseurs },
);
}
fournisseur = fournisseurs[0].codeFournisseur;
}
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes: [{ codein, gamme }],
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(
{ ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur },
{ meta: { enregistreLe: new Date().toISOString() } },
);
}
+32 -1
View File
@@ -578,4 +578,35 @@
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));
}
+172 -54
View File
@@ -8,7 +8,7 @@
* 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 { cn } from "@/lib/utils";
import { SearchInput, Select } from "@/components/ui/form-controls";
@@ -33,6 +33,26 @@ 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> {
@@ -198,6 +218,130 @@ 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 */}
@@ -249,52 +393,22 @@ export function DataTable<T>({
/>
) : (
<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 }}
>
<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>
<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>
)}
</thead>
<tbody>
{lignesAffichees.map((r, i) => (
@@ -302,39 +416,43 @@ export function DataTable<T>({
key={rowKey(r, i)}
onClick={onRowClick ? () => onRowClick(r) : undefined}
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",
)}
>
{columns.map((c) => (
{columns.map((c, ci) => (
<td
key={c.id}
style={styleFigee(ci)}
className={cn(
"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",
classesColonne(c, ci),
c.className,
)}
>
{c.cell(r)}
{borne(c, ci, c.cell(r))}
</td>
))}
</tr>
))}
</tbody>
{showFooter && (
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
<tr>
{columns.map((c) => (
<tfoot className="sticky bottom-0 z-10 font-semibold">
<tr className="bg-[var(--bg-elevated)]">
{columns.map((c, ci) => (
<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",
c.align === "right" && "text-right tabular-nums whitespace-nowrap",
c.align === "center" && "text-center",
classesColonne(c, ci),
)}
>
{c.footer ? c.footer(triees) : null}
{c.footer ? borne(c, ci, c.footer(triees)) : null}
</td>
))}
</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", {
id: serial("id").primaryKey(),
@@ -283,3 +283,26 @@ export const aiSupplierContext = pgTable("ai_supplier_context", {
/** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});
/**
* Gammes proposées par l'API `/api/v1`, en attente de validation.
*
* L'API n'enregistre plus directement : chaque proposition attend ici qu'un
* utilisateur l'examine dans la Grille, où elle apparaît comme une modification
* non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler
* les modifications » la rejette ; dans les deux cas la ligne est supprimée.
* Une seule proposition par article et fournisseur : la dernière l'emporte.
*/
export const gammesAValider = pgTable("gammes_a_valider", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
/** Gamme proposée. */
gamme: varchar("gamme", { length: 20 }).notNull(),
/** Gamme enregistrée au moment de la proposition. */
gammePrecedente: varchar("gamme_precedente", { length: 20 }),
/** Nom de la clé d'API à l'origine de la proposition. */
auteur: varchar("auteur", { length: 100 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
primaryKey({ columns: [table.codeFournisseur, table.codein] }),
]);
@@ -79,6 +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: "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)" },
];
@@ -203,6 +205,7 @@ export function ApiConnectionInfo() {
</Repli>
<Repli titre="Exemples d'appels">
<CodeLine>{`curl -X PUT -H "X-API-Key: VOTRE_CLE" -H "Content-Type: application/json" \\\n -d '{"gamme":"B"}' "${base}/products/123456/gamme"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
@@ -0,0 +1,80 @@
import "server-only";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
export interface ChangementGamme {
codein: string;
/** Gamme avant la modification (état serveur), conservée pour l'historique. */
codeGammeBefore: string | null;
codeGamme: string;
}
export interface EnregistrementGammes {
codeFournisseur: string;
nomFournisseur?: string | null;
magasin: string;
changes: ChangementGamme[];
userId: number | null;
/** Libellé du snapshot, « Draft — date » par défaut. */
label?: string;
}
/**
* Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
*
* Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
* puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
* Lève une erreur si l'écriture du snapshot échoue.
*/
export async function enregistrerGammes(input: EnregistrementGammes): Promise<number> {
const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input;
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 }>)
: {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
for (const c of changes) {
mergedChanges[c.codein] = {
before: c.codeGammeBefore,
after: c.codeGamme,
};
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
await db.insert(sessionSnapshots).values({
userId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
changes: mergedChanges,
summaryJson: null,
type: "snapshot",
});
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return changes.length;
}
@@ -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" };
}
}
+11 -49
View File
@@ -1,12 +1,9 @@
"use server";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
import { enregistrerGammes } from "./enregistrer-gammes";
import { supprimerGammesAValider } from "@/lib/gammes-a-valider";
const SaveDraftsSchema = z.object({
codeFournisseur: z.string(),
@@ -19,6 +16,11 @@ const SaveDraftsSchema = z.object({
codeGamme: z.string(),
})
),
/**
* Propositions de l'API examinées dans la Grille : validées (présentes dans
* `changes`) ou écartées. Elles quittent la file « à valider ».
*/
gammesAValiderTraitees: z.array(z.string()).optional(),
});
export async function saveDraftChanges(
@@ -32,56 +34,16 @@ export async function saveDraftChanges(
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
const finalUserId = userId && !isNaN(userId) ? userId : null;
try {
// 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 }>)
: {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
for (const c of changes) {
mergedChanges[c.codein] = {
before: c.codeGammeBefore,
after: c.codeGamme,
};
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
await db.insert(sessionSnapshots).values({
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
changes: mergedChanges,
summaryJson: null,
type: "snapshot",
});
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return { success: true, saved: changes.length };
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";
return { success: false, saved: 0, error: msg };
@@ -30,7 +30,6 @@ const PROFONDEURS = [100, 200, 500] as const;
export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1];
const nb = (v: number) => Math.round(v).toLocaleString("fr-FR");
const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`;
const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
/**
@@ -51,18 +50,18 @@ interface Critere {
}
const CRITERES: Record<string, Critere> = {
caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' },
caReseau: { label: "CA réseau", format: eur2, xlsx: '#,##0.00 "€"' },
qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" },
nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" },
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" },
prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' },
prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur2, xlsx: '#,##0.00 "€"' },
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`,
xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) },
totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" },
totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalCa: { label: "CA 12 m", format: eur2, xlsx: '#,##0.00 "€"' },
totalMarge: { label: "Marge 12 m", format: eur2, xlsx: '#,##0.00 "€"' },
};
function libelleCritere(id: string | null): string {
@@ -8,6 +8,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
import { rejeterGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import {
DropdownMenu,
@@ -19,13 +20,14 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import {
exporterFichierGammes,
exporterGammeA,
exporterTousProduits,
imprimerPdf,
LIGNES_MAX_PAR_FICHIER_GAMMES,
lignesModifiees,
} 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 {
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
return err instanceof Error ? err.message : String(err);
@@ -62,7 +72,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
const { save, hasDrafts, count, nbAValider } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
@@ -77,7 +87,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
startTransition(async () => {
try {
const result = await save();
if (result.success) {
if (result.success && result.saved === 0) {
toast.info("Propositions de l'API écartées.");
} else if (result.success) {
toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
} else {
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
@@ -89,16 +101,36 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
};
const handleReset = async () => {
const pluriel = count > 1 ? "s" : "";
const message = count > 0
? `Les ${count} gamme${pluriel} modifiée${pluriel} et non enregistrée${pluriel} reviendront à leur valeur précédente.`
: "";
const ok = await confirmer({
titre: "Annuler les modifications ?",
message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`,
message: nbAValider > 0
? `${message} ${nbAValider > 1 ? `Les ${nbAValider} gammes proposées` : "La gamme proposée"} par l'API ${nbAValider > 1 ? "seront rejetées" : "sera rejetée"}.`.trim()
: message,
libelleConfirmer: "Annuler les modifications",
danger: true,
});
if (ok) {
resetDrafts();
toast.info("Modifications annulées.");
if (!ok) return;
if (nbAValider > 0) {
const { gammesAValider, rowsByCodein, retirerGammesAValider } = useGridStore.getState();
const codeins = Object.keys(gammesAValider).filter((c) => rowsByCodein[c]);
const codeFournisseur = filterFournisseur || rows[0]?.codeFournisseur;
try {
const res = codeFournisseur
? await rejeterGammesAValider({ codeFournisseur, codeins })
: { success: false, error: "fournisseur inconnu" };
if (!res.success) throw new Error(res.error);
retirerGammesAValider(codeins);
} catch (err) {
toast.erreur(`Les propositions de l'API n'ont pas pu être rejetées : ${messageErreur(err)}`);
return;
}
}
resetDrafts();
toast.info("Modifications annulées.");
};
/**
@@ -165,10 +197,10 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
<div className="flex flex-wrap items-center gap-x-8 gap-y-1">
<Stat label="Produits actifs" value={fmtEntier(summary.totalRows)} sub={summary.totalProducts !== summary.totalRows ? `sur ${fmtEntier(summary.totalProducts)}` : undefined} subColor="var(--text-muted)" />
<Stat label="Quantités vendues (12 mois)" value={fmtEntier(summary.totalQuantite)} />
<Stat label="Chiffre d'affaires" value={fmtEur0(summary.totalCa)} />
<Stat label="Chiffre d'affaires" value={fmtEur2(summary.totalCa)} />
<Stat
label="Marge"
value={fmtEur0(summary.totalMarge)}
value={fmtEur2(summary.totalMarge)}
sub={`${fmtDecimal1(summary.tauxMargeGlobal)} %`}
subColor={couleurMarge(summary.tauxMargeGlobal)}
/>
@@ -194,7 +226,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
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);
return exporterFichierGammes(true).then(signalerDecoupage);
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
>
<FileSpreadsheet />
@@ -205,7 +237,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
onSelect={() => lancer("complet", () => exporterFichierGammes(false).then(signalerDecoupage), "Aucun produit à exporter.")}
>
<FileSpreadsheet />
<div className="flex flex-col">
@@ -256,6 +288,14 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
</DropdownMenuContent>
</DropdownMenu>
{nbAValider > 0 && (
<span
className="rounded-lg border px-2.5 py-1 text-[13px] font-medium border-[var(--accent-warning)] text-[var(--accent-warning)]"
title="Gammes proposées par l'API : elles apparaissent comme des modifications non enregistrées. « Enregistrer » les valide, « Annuler » les rejette."
>
{nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l&apos;API à valider
</span>
)}
{hasDrafts && (
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
<RotateCcw /> Annuler
@@ -265,9 +305,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
{isPending
? "Enregistrement…"
: hasDrafts
: count > 0
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
: "Aucune modification"}
: hasDrafts
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
: "Aucune modification"}
</Button>
</div>
</div>
@@ -7,6 +7,7 @@ import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolba
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
import { getGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
import type { ProductRow } from "@/types/grid";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -231,6 +232,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return () => controller.abort();
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
// Gammes proposées par l'API : relues une fois les lignes du fournisseur
// chargées (ou retrouvées en mémoire), puis affichées comme modifications
// non enregistrées, à valider.
const lignesChargeesLe = useGridStore((s) => (s.rowsMeta?.key === rowsKeyFor(codeFournisseur) ? s.rowsMeta.loadedAt : null));
useEffect(() => {
if (!isMounted || lignesChargeesLe === null) return;
let annule = false;
getGammesAValider(codeFournisseur)
.then((res) => {
if (annule) return;
if (!res.success) {
console.error("[grid] gammes à valider KO:", res.error);
return;
}
useGridStore.getState().chargerGammesAValider(res.propositions);
})
.catch((e) => console.error("[grid] gammes à valider KO:", e));
return () => { annule = true; };
}, [codeFournisseur, lignesChargeesLe, isMounted]);
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
useStorePatch(codeFournisseur, isMounted);
+50 -8
View File
@@ -14,7 +14,7 @@ import {
type FilterFn,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff, Layers } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -29,6 +29,7 @@ import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal";
import { NetworkMonthlyModal } from "@/features/grid/components/network-monthly-modal";
import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
import { NomenclatureCaModal } from "@/features/grid/components/nomenclature-ca-modal";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import {
@@ -45,7 +46,7 @@ import {
} from "@/features/grid/lib/network-trend";
import { TrendSparkline } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
@@ -243,6 +244,9 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
const isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined);
const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme);
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
// Proposition de l'API affichée telle quelle : à valider.
const proposition = useGridStore((s) => s.gammesAValider[codein]);
const proposeeParApi = isDraft && proposition !== undefined && proposition.gamme === effectiveGamme;
const isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null;
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
@@ -266,6 +270,14 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
isDraft={isDraft}
onChange={(g: GammeCode) => setDraftGamme(codein, g)}
/>
{proposeeParApi && (
<div
className="absolute -right-2 -top-2 z-10 rounded px-1 text-[9px] font-bold leading-[14px] text-white shadow-sm bg-[var(--accent-warning)]"
title={`Proposée par l'API${proposition.auteur ? ` (${proposition.auteur})` : ""} le ${new Date(proposition.proposeeLe).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} — à valider`}
>
API
</div>
)}
{isModified && !isDraft && (
<div className="absolute -right-2 -top-2 bg-emerald-500 text-white rounded-full p-0.5 shadow-sm z-10 animate-in zoom-in-50" title="Modifié et validé">
<Check className="w-2.5 h-2.5" />
@@ -720,6 +732,8 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const [monthlyModal, setMonthlyModal] = useState<ProductRow | null>(null);
// Trous d'assortiment : ce qu'un magasin ne travaille pas, en haut du classement affiché
const [gapsOpen, setGapsOpen] = useState(false);
// CA par nomenclature et par magasin, face au réseau, sur les lignes affichées
const [nomenclaturesOpen, setNomenclaturesOpen] = useState(false);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Les 12 mois viennent des données, pas de l'horloge du navigateur : les
@@ -891,7 +905,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const val = row.original.caReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
{val != null ? fmtEur0(val) : "-"}
{val != null ? fmtEur2(val) : "-"}
</div>
);
},
@@ -1020,7 +1034,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const val = row.original.caParMagasinReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? fmtEur0(val) : "-"}
{val != null ? fmtEur2(val) : "-"}
</div>
);
},
@@ -1110,14 +1124,14 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
id: "totalCa",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalCa : (row.caByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Chiffre d'affaires sur 12 mois">CA</div>,
size: 90,
size: 105,
cell: ({ row }: { row: { original: ProductRow } }) => {
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
return (
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(ca)}&nbsp;€
{fmtEur2(ca)}
</div>
);
},
@@ -1126,7 +1140,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
id: "totalMarge",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalMarge : (row.margeByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Marge en euros et taux de marge sur 12 mois">Marge</div>,
size: 110,
size: 115,
cell: ({ row }: { row: { original: ProductRow } }) => {
const marge = activeMagasin === "TOTAL"
? row.original.totalMarge
@@ -1138,7 +1152,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
return (
<div className="flex flex-col items-center justify-center">
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(marge)}&nbsp;€
{fmtEur2(marge)}
</span>
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
{taux.toFixed(1)}%
@@ -1259,6 +1273,12 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
}));
}, [gapsOpen, tableRows, sorting]);
/** Les produits affichés (filtres et recherche compris), pour le CA par nomenclature. */
const lignesAffichees: ProductRow[] = useMemo(
() => (nomenclaturesOpen ? tableRows.map((r) => r.original) : []),
[nomenclaturesOpen, tableRows],
);
if (!isMounted) {
return (
<div
@@ -1278,6 +1298,17 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2">
{/* CA par nomenclature et par magasin, comparé au réseau. */}
<Button
variant="outline"
size="sm"
onClick={() => setNomenclaturesOpen(true)}
disabled={tableRows.length === 0}
title="Le CA de chaque nomenclature sur la période, par magasin, comparé au CA réseau"
>
<Layers />
<span className="hidden lg:inline">Nomenclatures</span>
</Button>
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
<Button
variant="outline"
@@ -1453,6 +1484,17 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
/>
)}
</Dialog>
{/* CA par nomenclature : nos magasins face au réseau */}
<Dialog open={nomenclaturesOpen} onOpenChange={setNomenclaturesOpen}>
{nomenclaturesOpen && (
<NomenclatureCaModal
rows={lignesAffichees}
mois={MONTHS_12}
nomFournisseur={nomFournisseur}
onClose={() => setNomenclaturesOpen(false)}
/>
)}
</Dialog>
{/* Modal détail 12 mois (ventes / entrées / stock par magasin) */}
<Dialog open={monthlyModal !== null} onOpenChange={(open) => { if (!open) setMonthlyModal(null); }}>
{monthlyModal !== null && (
@@ -33,7 +33,7 @@ import {
} from "@/features/grid/lib/network-trend";
import { formatDate, formatMonthLabel, qlikMonthToFf } from "@/features/grid/lib/months";
import { MAGASINS, LIBELLE_TOUS_MAGASINS } from "@/lib/magasins";
import { fmtEur0 } from "@/lib/format";
import { fmtEur2 } from "@/lib/format";
import type { ProductRow } from "@/types/grid";
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
@@ -285,7 +285,7 @@ export function NetworkMonthlyModal({ row, trend, mois, onClose }: {
{row.caReseau != null && row.caReseau > 0 && (
<TuileStat
label="CA réseau 12 m"
valeur={fmtEur0(row.caReseau)}
valeur={fmtEur2(row.caReseau)}
indice={row.margePctReseau ? `marge ${fmt(row.margePctReseau * 100, 1)} %` : undefined}
/>
)}
@@ -0,0 +1,516 @@
"use client";
/**
* CollectFlow — CA par nomenclature : nos magasins face au réseau.
*
* On part des lignes AFFICHÉES dans la Grille (filtres de nomenclature, de
* gamme et recherche compris) et on les regroupe par nomenclature (`code3`).
* La période est celle de la Grille : les 12 mois complets glissants, sur
* laquelle sont calés à la fois les totaux FF par magasin et les chiffres
* réseau Qlik.
*
* Comparer des montants bruts n'aurait pas de sens — le réseau compte ~270
* magasins, nous deux. La comparaison passe donc par deux rapports :
* - le poids de la nomenclature dans le CA du périmètre, chez nous et dans
* le réseau (écart en points : où notre mix diverge de celui du réseau) ;
* - l'indice de chaque magasin face au magasin moyen du réseau
* (CA réseau ÷ 270) : 100 = au niveau du magasin moyen.
*/
import React, { useMemo, useState } from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown, FileSpreadsheet, Layers, Loader2, Search } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { formatMonthLabel } from "@/features/grid/lib/months";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import type { ProductRow } from "@/types/grid";
/** Une nomenclature et ses chiffres sur la période. */
interface LigneNomenclature {
code: string;
libelle: string;
nbProduits: number;
/** Produits vendus dans au moins un de nos magasins. */
nbVendus: number;
/** Produits sans chiffre réseau (non rapprochés dans Qlik). */
nbSansReseau: number;
caParSite: Record<string, number>;
caNous: number;
caReseau: number;
partNous: number | null;
partReseau: number | null;
/** Écart de poids, en points : partNous − partReseau. */
ecartPoints: number | null;
/** CA du magasin moyen du réseau (CA réseau ÷ nombre de magasins du réseau). */
caMagasinMoyenReseau: number;
}
type CleTri = "libelle" | "caNous" | "caReseau" | "partNous" | "partReseau" | "ecartPoints" | `site:${string}`;
const SANS_NOMENCLATURE = "—";
const fmtPct = (v: number | null) => (v == null ? "—" : `${fmtDecimal1(v * 100)} %`);
const fmtPoints = (v: number | null) => (v == null ? "—" : `${v >= 0 ? "+" : ""}${fmtDecimal1(v * 100)} pts`);
const fmtEurOuTiret = (v: number) => (v !== 0 ? fmtEur2(v) : "—");
/** Indice d'un magasin face au magasin moyen du réseau (100 = au niveau). */
function indice(ca: number, caMagasinMoyen: number): number | null {
if (caMagasinMoyen <= 0) return null;
return (ca / caMagasinMoyen) * 100;
}
function couleurIndice(v: number | null): string {
if (v == null) return "var(--text-muted)";
return v >= 100 ? "var(--accent-success)" : "var(--accent-error)";
}
function couleurEcart(v: number | null): string {
if (v == null || Math.abs(v) < 0.005) return "var(--text-muted)";
return v > 0 ? "var(--accent-success)" : "var(--accent-error)";
}
/** Magasins présents dans les données, à défaut nos magasins connus — dans l'ordre de `MAGASINS`. */
function sitesDe(rows: ProductRow[]): string[] {
const trouves = new Set<string>();
for (const r of rows) for (const s of Object.keys(r.caByStore ?? {})) trouves.add(s);
const connus = MAGASINS.map((m) => m.code);
const autres = [...trouves].filter((s) => !connus.includes(s)).sort();
return [...connus, ...autres];
}
function regrouper(rows: ProductRow[], sites: string[]): { lignes: LigneNomenclature[]; total: LigneNomenclature } {
const groupes = new Map<string, LigneNomenclature>();
const vide = (code: string, libelle: string): LigneNomenclature => ({
code,
libelle,
nbProduits: 0,
nbVendus: 0,
nbSansReseau: 0,
caParSite: Object.fromEntries(sites.map((s) => [s, 0])),
caNous: 0,
caReseau: 0,
partNous: null,
partReseau: null,
ecartPoints: null,
caMagasinMoyenReseau: 0,
});
const total = vide("", "Total");
for (const r of rows) {
const code = r.code3?.trim() || SANS_NOMENCLATURE;
let g = groupes.get(code);
if (!g) {
g = vide(code, r.libelle3?.trim() || (code === SANS_NOMENCLATURE ? "Sans nomenclature" : code));
groupes.set(code, g);
}
let caProduit = 0;
for (const s of sites) {
const ca = r.caByStore?.[s] ?? 0;
g.caParSite[s] += ca;
total.caParSite[s] += ca;
caProduit += ca;
}
// Lignes sans détail par magasin : le total du produit reste compté.
if (!r.caByStore && r.totalCa) caProduit = r.totalCa;
g.caNous += caProduit;
total.caNous += caProduit;
for (const cible of [g, total]) {
cible.nbProduits += 1;
if (caProduit > 0) cible.nbVendus += 1;
if (r.caReseau == null) cible.nbSansReseau += 1;
else cible.caReseau += r.caReseau;
}
}
const lignes = [...groupes.values()].filter((g) => g.caNous !== 0 || g.caReseau !== 0);
for (const g of [...lignes, total]) {
g.partNous = total.caNous > 0 ? g.caNous / total.caNous : null;
g.partReseau = total.caReseau > 0 ? g.caReseau / total.caReseau : null;
g.ecartPoints = g.partNous != null && g.partReseau != null ? g.partNous - g.partReseau : null;
g.caMagasinMoyenReseau = g.caReseau / NB_MAGASINS_RESEAU;
}
return { lignes, total };
}
function valeurTri(l: LigneNomenclature, cle: CleTri): number | string {
if (cle === "libelle") return l.libelle.toLowerCase();
if (cle.startsWith("site:")) return l.caParSite[cle.slice(5)] ?? 0;
const v = l[cle as Exclude<CleTri, "libelle" | `site:${string}`>];
// Valeur absente : toujours en bas, quel que soit le sens.
return v ?? Number.NaN;
}
/**
* Classeur Excel de la liste affichée — valeurs numériques brutes pour que le
* tableur puisse trier et sommer. `exceljs` est chargé à la demande.
*/
async function construireClasseur(
lignes: LigneNomenclature[],
total: LigneNomenclature,
sites: string[],
contexte: { fournisseur?: string; periode: string; nbLignesGrille: number },
): Promise<Blob> {
const { Workbook } = await import("exceljs");
const classeur = new Workbook();
classeur.creator = "CollectFlow";
classeur.created = new Date();
const feuille = classeur.addWorksheet("CA par nomenclature", { views: [{ state: "frozen", ySplit: 1 }] });
feuille.columns = [
{ header: "Code", key: "code", width: 12 },
{ header: "Nomenclature", key: "libelle", width: 36 },
{ header: "Produits", key: "nbProduits", width: 10 },
{ header: "Produits vendus", key: "nbVendus", width: 15 },
...sites.flatMap((s) => [
{ header: `CA ${nomMagasin(s, { court: true })}`, key: `ca_${s}`, width: 16 },
{ header: `Indice ${nomMagasin(s, { court: true })}`, key: `ind_${s}`, width: 14 },
]),
{ header: "CA nos magasins", key: "caNous", width: 16 },
{ header: "Poids chez nous", key: "partNous", width: 15 },
{ header: "CA réseau", key: "caReseau", width: 16 },
{ header: "CA magasin moyen réseau", key: "caMoyen", width: 22 },
{ header: "Poids réseau", key: "partReseau", width: 13 },
{ header: "Écart de poids (pts)", key: "ecart", width: 18 },
{ header: "Produits sans chiffre réseau", key: "nbSansReseau", width: 24 },
];
feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } };
feuille.getRow(1).height = 20;
const ajouter = (l: LigneNomenclature) => feuille.addRow({
code: l.code,
libelle: l.libelle,
nbProduits: l.nbProduits,
nbVendus: l.nbVendus,
...Object.fromEntries(sites.flatMap((s) => {
const ind = indice(l.caParSite[s] ?? 0, l.caMagasinMoyenReseau);
return [[`ca_${s}`, l.caParSite[s] ?? 0], [`ind_${s}`, ind == null ? "" : Math.round(ind)]];
})),
caNous: l.caNous,
partNous: l.partNous ?? "",
caReseau: l.caReseau,
caMoyen: l.caMagasinMoyenReseau,
partReseau: l.partReseau ?? "",
ecart: l.ecartPoints == null ? "" : Math.round(l.ecartPoints * 1000) / 10,
nbSansReseau: l.nbSansReseau,
});
for (const l of lignes) ajouter(l);
const ligneTotal = ajouter(total);
ligneTotal.font = { bold: true };
const eur = '#,##0.00 "€"';
for (const cle of ["caNous", "caReseau", "caMoyen", ...sites.map((s) => `ca_${s}`)]) feuille.getColumn(cle).numFmt = eur;
feuille.getColumn("partNous").numFmt = "0.0 %";
feuille.getColumn("partReseau").numFmt = "0.0 %";
feuille.getColumn("ecart").numFmt = '+0.0;-0.0;0.0';
feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } };
const contexteFeuille = classeur.addWorksheet("Contexte");
contexteFeuille.columns = [{ width: 26 }, { width: 80 }];
for (const [cle, valeur] of [
["Extraction", "CA par nomenclature — nos magasins et réseau"],
["Fournisseur", contexte.fournisseur ?? "—"],
["Période", contexte.periode],
["Périmètre", `${contexte.nbLignesGrille} produits affichés dans la Grille (filtres compris)`],
["Poids", "part de la nomenclature dans le CA du périmètre (chez nous / dans le réseau)"],
["Indice magasin", `CA du magasin ÷ CA du magasin moyen du réseau (CA réseau ÷ ${NB_MAGASINS_RESEAU}) × 100`],
["Généré le", new Date().toLocaleString("fr-FR")],
]) {
contexteFeuille.addRow([cle, valeur]).getCell(1).font = { bold: true };
}
const buffer = await classeur.xlsx.writeBuffer();
return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
}
function EnTete({ label, cle, tri, onTri, align = "right", title }: {
label: React.ReactNode;
cle: CleTri;
tri: { cle: CleTri; desc: boolean };
onTri: (cle: CleTri) => void;
align?: "left" | "right";
title?: string;
}) {
const actif = tri.cle === cle;
const Icone = !actif ? ChevronsUpDown : tri.desc ? ChevronDown : ChevronUp;
return (
<th
className={`px-2.5 py-1.5 font-semibold whitespace-nowrap ${align === "left" ? "text-left" : "text-right"}`}
style={{ color: actif ? "var(--text-primary)" : "var(--text-secondary)" }}
aria-sort={actif ? (tri.desc ? "descending" : "ascending") : "none"}
title={title}
>
<button
type="button"
onClick={() => onTri(cle)}
className={`inline-flex items-center gap-1 ${align === "left" ? "" : "flex-row-reverse"}`}
>
{label}
<Icone className="w-3 h-3 shrink-0 opacity-60" />
</button>
</th>
);
}
export function NomenclatureCaModal({ rows, mois, nomFournisseur, onClose }: {
/** Les produits affichés dans la Grille (filtres et recherche compris). */
rows: ProductRow[];
/** Les 12 clés de mois de la Grille, du plus ancien au plus récent. */
mois: string[];
nomFournisseur?: string;
onClose: () => void;
}) {
const sites = useMemo(() => sitesDe(rows), [rows]);
const { lignes, total } = useMemo(() => regrouper(rows, sites), [rows, sites]);
const [tri, setTri] = useState<{ cle: CleTri; desc: boolean }>({ cle: "caNous", desc: true });
const [recherche, setRecherche] = useState("");
const [exportEnCours, setExportEnCours] = useState(false);
const [exportErreur, setExportErreur] = useState<string | null>(null);
const changerTri = (cle: CleTri) =>
setTri((t) => (t.cle === cle ? { cle, desc: !t.desc } : { cle, desc: cle !== "libelle" }));
const affichees = useMemo(() => {
const q = recherche.trim().toLowerCase();
const filtrees = q
? lignes.filter((l) => l.libelle.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
: lignes;
const sens = tri.desc ? -1 : 1;
return [...filtrees].sort((a, b) => {
const va = valeurTri(a, tri.cle);
const vb = valeurTri(b, tri.cle);
if (typeof va === "string" || typeof vb === "string") return String(va).localeCompare(String(vb), "fr") * sens;
if (Number.isNaN(va)) return Number.isNaN(vb) ? 0 : 1;
if (Number.isNaN(vb)) return -1;
return (va - vb) * sens;
});
}, [lignes, recherche, tri]);
const periode = mois.length > 0
? `${formatMonthLabel(mois[0])} → ${formatMonthLabel(mois[mois.length - 1])}`
: "12 derniers mois";
const absentesChezNous = lignes.filter((l) => l.caNous <= 0 && l.caReseau > 0).length;
const indicesTotal = sites.map((s) => indice(total.caParSite[s] ?? 0, total.caMagasinMoyenReseau));
const exporter = async () => {
if (affichees.length === 0 || exportEnCours) return;
setExportEnCours(true);
setExportErreur(null);
try {
const blob = await construireClasseur(affichees, total, sites, {
fournisseur: nomFournisseur,
periode,
nbLignesGrille: rows.length,
});
const url = URL.createObjectURL(blob);
const lien = document.createElement("a");
lien.href = url;
lien.download = [
"CA_nomenclatures",
nomFournisseur?.replace(/[^\w]+/g, "_"),
new Date().toISOString().slice(0, 10),
].filter(Boolean).join("_") + ".xlsx";
lien.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error("[CA par nomenclature] export Excel :", e);
setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste.");
} finally {
setExportEnCours(false);
}
};
const celluleSite = (l: LigneNomenclature, s: string, gras = false) => {
const ca = l.caParSite[s] ?? 0;
const ind = indice(ca, l.caMagasinMoyenReseau);
return (
<td key={s} className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className={gras ? "font-bold" : "font-semibold"} style={{ color: ca !== 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
{fmtEurOuTiret(ca)}
</div>
<div className="text-[11px]" style={{ color: couleurIndice(ind) }} title="Indice face au magasin moyen du réseau (100 = au niveau)">
{ind == null ? "—" : `indice ${fmtEntier(ind)}`}
</div>
</td>
);
};
return (
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-6xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<Layers className="w-5 h-5 shrink-0" style={{ color: "var(--accent)" }} />
CA par nomenclature
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
{nomFournisseur ? <>{nomFournisseur} — </> : null}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{periode}</span>
{` — ${fmtEntier(rows.length)} produits affichés dans la Grille, par magasin et face au réseau.`}
</p>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<TuileStat label="Nomenclatures" valeur={fmtEntier(lignes.length)} indice="avec du CA chez nous ou dans le réseau" />
{sites.map((s, i) => (
<TuileStat
key={s}
label={`CA ${nomMagasin(s)}`}
valeur={fmtEur2(total.caParSite[s] ?? 0)}
indice={indicesTotal[i] == null ? undefined : `indice ${fmtEntier(indicesTotal[i]!)} face au magasin moyen du réseau`}
/>
))}
<TuileStat
label="CA réseau"
valeur={fmtEur2(total.caReseau)}
indice={`soit ${fmtEur2(total.caMagasinMoyenReseau)} par magasin moyen (${NB_MAGASINS_RESEAU} magasins)`}
/>
<TuileStat
label="Absentes chez nous"
valeur={fmtEntier(absentesChezNous)}
indice="nomenclatures vendues dans le réseau, sans CA dans nos magasins"
couleur={absentesChezNous > 0 ? "var(--accent-warning)" : undefined}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5" style={{ color: "var(--text-muted)" }} />
<input
type="search"
placeholder="Filtrer les nomenclatures…"
aria-label="Filtrer les nomenclatures"
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
className="apple-input w-60"
style={{ paddingLeft: "32px" }}
/>
</div>
<button
type="button"
onClick={exporter}
disabled={affichees.length === 0 || exportEnCours}
title="Exporter la liste affichée au format Excel"
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
>
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
{exportEnCours ? "Génération…" : "Export Excel"}
</button>
</div>
{exportErreur && (
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
{exportErreur}
</div>
)}
{affichees.length === 0 ? (
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
{lignes.length === 0
? "Aucune nomenclature avec du CA sur la période pour les produits affichés."
: "Aucune nomenclature ne correspond à ce filtre."}
</div>
) : (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]" style={{ minWidth: 900 }}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<EnTete label="Nomenclature" cle="libelle" tri={tri} onTri={changerTri} align="left" />
{sites.map((s) => (
<EnTete key={s} label={`CA ${nomMagasin(s, { court: true })}`} cle={`site:${s}`} tri={tri} onTri={changerTri}
title={`CA de ${nomMagasin(s)} sur la période, et son indice face au magasin moyen du réseau`} />
))}
<EnTete label="CA nos magasins" cle="caNous" tri={tri} onTri={changerTri} />
<EnTete label="Poids chez nous" cle="partNous" tri={tri} onTri={changerTri}
title="Part de la nomenclature dans le CA de nos magasins sur le périmètre affiché" />
<EnTete label="CA réseau" cle="caReseau" tri={tri} onTri={changerTri}
title={`CA dans les magasins du réseau ; en dessous, le CA du magasin moyen (÷ ${NB_MAGASINS_RESEAU})`} />
<EnTete label="Poids réseau" cle="partReseau" tri={tri} onTri={changerTri}
title="Part de la nomenclature dans le CA réseau sur le périmètre affiché" />
<EnTete label="Écart" cle="ecartPoints" tri={tri} onTri={changerTri}
title="Poids chez nous − poids réseau, en points : positif = nomenclature plus forte chez nous que dans le réseau" />
</tr>
</thead>
<tbody>
{affichees.map((l) => (
<tr key={l.code} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }}>
<div className="font-semibold">{l.libelle}</div>
<div className="text-[11px] tabular-nums" style={{ color: "var(--text-muted)" }}>
{l.code !== SANS_NOMENCLATURE && <>{l.code} · </>}
{fmtEntier(l.nbVendus)} / {fmtEntier(l.nbProduits)} produits vendus
{l.nbSansReseau > 0 && (
<span title="Produits sans chiffre réseau (non rapprochés dans Qlik) : le CA réseau de la nomenclature est sous-estimé">
{" "}· {fmtEntier(l.nbSansReseau)} sans réseau
</span>
)}
</div>
</td>
{sites.map((s) => celluleSite(l, s))}
<td className="px-2.5 py-1.5 text-right tabular-nums font-bold whitespace-nowrap" style={{ color: l.caNous !== 0 ? "var(--text-primary)" : "var(--accent-warning)" }}>
{fmtEurOuTiret(l.caNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(l.partNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className="font-semibold text-emerald-600 dark:text-emerald-400">{fmtEurOuTiret(l.caReseau)}</div>
<div className="text-[11px]" style={{ color: "var(--text-muted)" }}>
{l.caReseau !== 0 ? `${fmtEur2(l.caMagasinMoyenReseau)} / mag.` : ""}
</div>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(l.partReseau)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: couleurEcart(l.ecartPoints) }}>
{fmtPoints(l.ecartPoints)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: "2px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
Total
<div className="text-[11px] font-normal tabular-nums" style={{ color: "var(--text-muted)" }}>
{fmtEntier(total.nbVendus)} / {fmtEntier(total.nbProduits)} produits vendus
</div>
</td>
{sites.map((s) => celluleSite(total, s, true))}
<td className="px-2.5 py-1.5 text-right tabular-nums font-bold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
{fmtEur2(total.caNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(total.partNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className="font-bold text-emerald-600 dark:text-emerald-400">{fmtEur2(total.caReseau)}</div>
<div className="text-[11px]" style={{ color: "var(--text-muted)" }}>{fmtEur2(total.caMagasinMoyenReseau)} / mag.</div>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(total.partReseau)}
</td>
<td className="px-2.5 py-1.5" />
</tr>
</tfoot>
</table>
</div>
)}
<p className="text-xs leading-snug" style={{ color: "var(--text-muted)" }}>
Période : les 12 mois complets de la Grille, communs à nos magasins et au réseau. Le poids est la part de la
nomenclature dans le CA des produits affichés ; l&apos;écart compare notre mix à celui du réseau. L&apos;indice
rapporte le CA d&apos;un magasin à celui du magasin moyen du réseau (CA réseau ÷ {NB_MAGASINS_RESEAU}) : 100 = au niveau.
{total.nbSansReseau > 0 && ` ${fmtEntier(total.nbSansReseau)} produits n'ont pas de chiffre réseau : le CA réseau des nomenclatures concernées est sous-estimé.`}
</p>
</DialogContent>
);
}
+15 -5
View File
@@ -23,9 +23,15 @@ export function useSaveDrafts(magasin: string) {
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return n;
});
/** Propositions de l'API en attente sur les lignes chargées. */
const nbAValider = useGridStore((s) => {
let n = 0;
for (const codein in s.gammesAValider) if (s.rowsByCodein[codein]) n++;
return n;
});
const save = useCallback(async () => {
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
const { draftChanges, rowsByCodein, rows, gammesAValider, applyDraftsToRows, clearDrafts, retirerGammesAValider } = useGridStore.getState();
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
@@ -37,15 +43,18 @@ export function useSaveDrafts(magasin: string) {
codeGamme: codeGamme as GammeCode,
};
});
// Propositions de l'API examinées : enregistrées si elles sont restées en
// modification, écartées sinon. Dans les deux cas, elles sont traitées.
const gammesAValiderTraitees = Object.keys(gammesAValider).filter((codein) => rowsByCodein[codein]);
if (changes.length === 0) return { success: true, saved: 0 };
if (changes.length === 0 && gammesAValiderTraitees.length === 0) return { success: true, saved: 0 };
// Fournisseur déduit de la première ligne modifiée
const firstRow = rowsByCodein[changes[0].codein];
const firstRow = rowsByCodein[changes[0]?.codein ?? gammesAValiderTraitees[0]] ?? rows[0];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees });
if (result.success) {
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
@@ -53,9 +62,10 @@ export function useSaveDrafts(magasin: string) {
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
clearDrafts(Object.keys(activeDrafts));
retirerGammesAValider(gammesAValiderTraitees);
}
return result;
}, [magasin]);
return { save, hasDrafts: count > 0, count };
return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider };
}
+36 -16
View File
@@ -32,14 +32,24 @@ export function lignesModifiees(): ProductRow[] {
}
/**
* Fichier d'import des gammes (CODE FOURNISSEUR, GENCOD, GAMME), généré par le
* serveur. `seulementModifiees` : uniquement les gammes changées.
* Renvoie `false` s'il n'y a rien à exporter.
* 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 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 lignes = seulementModifiees ? lignesModifiees() : rows;
if (lignes.length === 0) return false;
if (lignes.length === 0) return 0;
const nomFournisseur = lignes[0].nomFournisseur;
const changes = lignes.map((r) => ({
@@ -49,16 +59,25 @@ 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";
telecharger(await res.blob(), `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}.xlsx`);
return true;
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;
}
/** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */
@@ -143,6 +162,7 @@ export async function imprimerPdf(nomFournisseur: string): Promise<boolean> {
doc.setTextColor(100);
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
const eur = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const head = [["Code-barres", "Code article", "Réf.", "Libellé", "Magasins réseau", "Qté", "CA", "Marge", "Gamme"]];
const body = rows.map((r) => [
r.gtin || r.codein,
@@ -151,8 +171,8 @@ export async function imprimerPdf(nomFournisseur: string): Promise<boolean> {
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
`${eur(r.totalCa)} €`,
`${eur(r.totalMarge)} €\n(${r.tauxMarge.toFixed(1)}%)`,
gammeEffective(r, draftChanges) || "-",
]);
+45
View File
@@ -15,6 +15,14 @@ export interface RowsMeta {
patchedStores: string[];
}
/** Gamme proposée par l'API, en attente de validation (cf. lib/gammes-a-valider). */
export interface GammeProposee {
gamme: string;
auteur: string | null;
/** Date ISO de la proposition. */
proposeeLe: string;
}
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
@@ -25,6 +33,20 @@ interface GridState {
rowsByCodein: Record<string, ProductRow>;
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
/**
* Propositions de l'API en attente pour le fournisseur affiché (codein →
* proposition). Non persisté : relu sur le serveur à chaque chargement.
*/
gammesAValider: Record<string, GammeProposee>;
/**
* Range les propositions de l'API et les reporte en modifications non
* enregistrées — sauf là où l'utilisateur a déjà sa propre modification, et
* sauf celles déjà connues (une proposition écartée à la main ne revient pas
* à chaque retour sur la Grille).
*/
chargerGammesAValider: (propositions: ReadonlyArray<{ codein: string } & GammeProposee>) => void;
/** Retire des propositions traitées (validées ou rejetées). */
retirerGammesAValider: (codeins: string[]) => void;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
@@ -141,6 +163,7 @@ export const useGridStore = create<GridState>()(
rowsByCodein: {},
activeMagasin: "TOTAL",
draftChanges: {},
gammesAValider: {},
filters: {
magasin: null,
codeFournisseur: null,
@@ -211,6 +234,28 @@ export const useGridStore = create<GridState>()(
}, 80);
},
chargerGammesAValider: (propositions) => {
const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
const gammesAValider: Record<string, GammeProposee> = {};
const nouvelles: Record<string, GammeCode> = {};
for (const p of propositions) {
const row = rowsByCodein[p.codein];
if (!row) continue;
gammesAValider[p.codein] = { gamme: p.gamme, auteur: p.auteur, proposeeLe: p.proposeeLe };
const dejaConnue = connues[p.codein]?.proposeeLe === p.proposeeLe;
if (!dejaConnue && draftChanges[p.codein] === undefined && p.gamme !== row.codeGamme) {
nouvelles[p.codein] = p.gamme as GammeCode;
}
}
set({ gammesAValider });
if (Object.keys(nouvelles).length > 0) get().batchSetDraftGamme(nouvelles);
},
retirerGammesAValider: (codeins) => {
const gammesAValider = { ...get().gammesAValider };
codeins.forEach((id) => delete gammesAValider[id]);
set({ gammesAValider });
},
resetDrafts: () => {
set({ draftChanges: {} });
if (summaryTimer) clearTimeout(summaryTimer);
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { StoreBadge } from "@/components/ui/badge";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
@@ -228,7 +228,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
<div>
<dt className="text-xs font-medium text-[var(--text-muted)]">Chiffre d&apos;affaires</dt>
<dd className="text-sm font-semibold tabular-nums text-[var(--text-primary)]">
{fmtEur0(resume.totalCa ?? 0)}
{fmtEur2(resume.totalCa ?? 0)}
</dd>
</div>
{taux != null && Number.isFinite(taux) && (
+12 -1
View File
@@ -22,6 +22,7 @@ import "server-only";
import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
import { gammesAValiderParArticle } from "@/lib/gammes-a-valider";
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
@@ -51,6 +52,11 @@ export interface EnrichedProductRow extends ProductRow {
* `null` si l'article n'a aucune gamme en base.
*/
codeGammeServeur: string | null;
/**
* Gamme proposée par l'API et pas encore validée dans la Grille.
* `null` = aucune proposition en attente.
*/
gammeAValider: string | null;
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
network: NetworkMetricCached | null;
/**
@@ -73,7 +79,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<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] = await Promise.all([
const [network, gammes, aValider] = await Promise.all([
codesCentraux.length
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
@@ -86,6 +92,10 @@ export async function enrichRows(rows: ProductRow[]): Promise<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) => {
@@ -97,6 +107,7 @@ 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
};
+127
View File
@@ -0,0 +1,127 @@
/**
* 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`.
*
* 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.
*/
import "server-only";
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";
export interface ResultatGamme {
codein: string;
/** Gamme proposée. */
gamme: string;
/** Gamme enregistrée au moment de l'appel (modifications validé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).
*/
modifie: boolean;
/** `a_valider` quand `modifie`, sinon `inchangee`. */
statut: "a_valider" | "inchangee";
}
export interface AffectationGammes {
codeFournisseur: string;
nomFournisseur: string | null;
resultats: ResultatGamme[];
}
/** Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. */
export async function lireCorpsJson(req: NextRequest): Promise<unknown | Response> {
try {
return await req.json();
} catch {
return fail("bad_request", "Corps de requête JSON attendu (Content-Type: application/json).");
}
}
/**
* 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
* demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
* articles lui appartiennent.
*/
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. */
auteur: string;
}): Promise<AffectationGammes | Response> {
const { codeFournisseur, compute, auteur } = input;
// Un même article cité deux fois : la dernière valeur l'emporte.
const demandes = new Map(input.changes.map((c) => [c.codein, c.gamme]));
const codeins = [...demandes.keys()];
if (compute && !(await getGridFreshness(codeFournisseur))) {
console.log(`[api/v1/gammes] instantané absent pour ${codeFournisseur} — calcul à la demande`);
try {
await getProductRows({ codeFournisseur, magasin: "TOTAL" });
} catch (e) {
console.error(`[api/v1/gammes] calcul KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e));
return fail("internal_error", `Le calcul de la grille a échoué pour le fournisseur « ${codeFournisseur} ».`);
}
}
const actuelles = await getGridRowsGammes(codeFournisseur, codeins);
const inconnus = codeins.filter((c) => !actuelles.has(c));
if (inconnus.length > 0) {
return fail(
"not_found",
`${inconnus.length} article(s) introuvable(s) chez le fournisseur « ${codeFournisseur} » : aucune gamme n'a été enregistrée.`,
{ fournisseur: codeFournisseur, inconnus },
);
}
const resultats: ResultatGamme[] = [];
const propositions: PropositionGamme[] = [];
const annules: string[] = [];
let nomFournisseur: string | null = null;
for (const [codein, gamme] of demandes) {
const actuelle = actuelles.get(codein)!;
nomFournisseur ??= actuelle.nomFournisseur;
const modifie = actuelle.codeGamme !== gamme;
resultats.push({
codein,
gamme,
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);
}
try {
await proposerGammes({ codeFournisseur, propositions, annules, auteur });
} 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.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`);
return { codeFournisseur, nomFournisseur, resultats };
}
+25
View File
@@ -8,6 +8,7 @@
import { z } from "zod";
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
import { GAMMES, type CodeGamme } from "@/lib/gammes";
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
@@ -116,6 +117,30 @@ export const productDetailSchema = z.object({
compute: z.enum(["0", "1"]).default("1"),
});
const CODES_GAMME = GAMMES.map((g) => g.code) as [CodeGamme, ...CodeGamme[]];
const gammeShape = z.enum(CODES_GAMME, {
message: `Gamme inconnue. Valeurs acceptées : ${CODES_GAMME.join(", ")}`,
});
/** `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. */
export const productGammeBodySchema = z.object({
gamme: gammeShape,
/** Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. */
fournisseur: z.string().min(1).optional(),
/** `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. */
compute: z.boolean().default(true),
});
/** `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. */
export const gammesBatchBodySchema = z.object({
fournisseur: z.string().min(1, "Champ 'fournisseur' requis"),
changes: z
.array(z.object({ codein: z.string().min(1), gamme: gammeShape }))
.min(1, "Au moins un changement attendu")
.max(10_000, "10 000 changements au maximum par appel"),
compute: z.boolean().default(true),
});
/**
* Projection de champs : `?fields=codein,libelle1,totalCa`.
*
+7 -4
View File
@@ -9,7 +9,7 @@
const nfEntier = new Intl.NumberFormat("fr-FR");
const nfDecimal1 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 1 });
const nfEur0 = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
const nfDecimal2 = new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const nfEur2 = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
@@ -23,8 +23,11 @@ export const fmtEntier = (v: number): string => nfEntier.format(Math.round(v));
/** Nombre avec au plus une décimale : `12,5`. */
export const fmtDecimal1 = (v: number): string => nfDecimal1.format(v);
/** Montant en euros sans centimes : `12 345 €`. */
export const fmtEur0 = (v: number): string => nfEur0.format(v);
/**
* Montant au centime, sans symbole (colonnes déjà libellées « (€) ») : `12 345,67`.
* Les montants ne sont jamais arrondis à l'euro dans l'application.
*/
export const fmtDecimal2 = (v: number): string => nfDecimal2.format(v);
/** Montant en euros au centime (prix unitaires) : `3,49 €`. */
/** Montant en euros au centime (prix, CA, marge) : `12 345,67 €`. */
export const fmtEur2 = (v: number): string => nfEur2.format(v);
+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)));
}
+49
View File
@@ -314,6 +314,55 @@ export async function getGridRowByCodein(
};
}
/** Fournisseurs chez lesquels un article figure dans l'instantané. */
export async function listGridSuppliersForCodein(
codein: string,
): Promise<Array<{ codeFournisseur: string; nomFournisseur: string | null }>> {
return db
.select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur })
.from(gridRows)
.where(eq(gridRows.codein, codein))
.orderBy(asc(gridRows.codeFournisseur));
}
export interface GammeInstantane {
/** Gamme courante, modifications enregistrées comprises. */
codeGamme: string | null;
/** Gamme serveur au moment du calcul de la grille. */
codeGammeInit: string | null;
nomFournisseur: string | null;
}
/**
* Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
* carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
*/
export async function getGridRowsGammes(
codeFournisseur: string,
codeins: readonly string[],
): Promise<Map<string, GammeInstantane>> {
const out = new Map<string, GammeInstantane>();
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
for (let i = 0; i < codeins.length; i += CHUNK) {
const found = await db
.select({
codein: gridRows.codein,
codeGamme: gridRows.codeGamme,
codeGammeInit: gridRows.codeGammeInit,
nomFournisseur: gridRows.nomFournisseur,
})
.from(gridRows)
.where(and(
eq(gridRows.codeFournisseur, codeFournisseur),
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
));
for (const r of found) {
out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur });
}
}
return out;
}
/**
* Indique si un fournisseur a déjà été calculé au moins une fois.
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
+5 -1
View File
@@ -880,6 +880,8 @@ export async function pgGetCaByNomenclature(
export interface HitParadeRow {
codein: string;
libelle: string;
/** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */
reference: string;
fournisseur: string;
nomenclature_code: string;
nomenclature: string;
@@ -929,12 +931,13 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
a.libelle1::text AS libelle,
COALESCE(TRIM(af.reference::text), '') AS reference,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code
SELECT af1.code, af1.reference
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
ORDER BY af1.code
@@ -959,6 +962,7 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
SELECT
v.codein,
COALESCE(ar.libelle, '') AS libelle,
COALESCE(ar.reference, '') AS reference,
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,