Lot 6 : Grille plus légère, Commandes allégées et nettoyage

- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-04 06:48:43 +00:00
1 parent 26b9364232
commit af5933b5a7
23 files changed
+324 -13441

No files matched your search

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+27
View File
@@ -409,3 +409,30 @@ Autres changements :
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
## Suivi — Lot 6 réalisé (performances restantes et nettoyage)
**Grille**
- **Écriture des réglages dans le navigateur** : au plus une par demi-seconde, et immédiate quand la page est quittée. Avant, chaque changement d'état en déclenchait une. Mesure : 8 changements de gamme donnent 1 écriture au lieu de 16.
- **Calculs faits une seule fois par ligne** : la tendance réseau (au lieu d'un calcul par case et par rendu) et le texte de recherche (au lieu d'un calcul par ligne et par frappe). La petite courbe de tendance est mémorisée.
- **Barre du bas** : elle ne suit plus que le nombre de modifications ; le détail est lu au moment d'enregistrer.
**Commandes fournisseurs**
- **Montants de franco** : demandés à l'API FF 6 fournisseurs à la fois, au lieu de tous en même temps.
- **Dernière réception** : calculée pour les seuls fournisseurs du cadencier. Sur la base de test, les résultats sont identiques au calcul complet.
**Nettoyage**
- **Dépendances retirées** : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `remark-gfm`, `xlsx`, `@radix-ui/react-dropdown-menu`. Un `npm ci` complet a été vérifié.
- **`pnpm-lock.yaml` supprimé** : Docker et le README utilisent npm.
- **Code mort retiré** :
- `ui/textarea`, `debug/api/init-db`, `score/store` ;
- `getGridData` et `getAvailableNomenclature` ;
- 5 fonctions de l'ancien client HTTP FF, `pgGetArticlesByCodeCentrale` et `libelleGamme`.
- **Journaux à la racine supprimés** : `build.log`, `build_logs.txt`, `error.txt`, `tsc_errors.log`.
**Non fait volontairement**
- **Enrichissement de l'Accueil en SQL** :
- le cache de la page sert la version précédente pendant son recalcul, donc le gain serait faible ;
- et rien ne garantit que la base FF relie chaque mouvement à son fournisseur (colonne détectée au cas par cas) : le fournisseur affiché pourrait changer.
- À reprendre après le diagnostic du lot 7.
- **Largeurs de colonnes en variables CSS** : peu de gain maintenant que l'écriture des réglages est différée.
BIN
View File
Binary file not shown.
+104 -2002
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -12,12 +12,8 @@
"docker:build": "docker-compose up -d --build"
},
"dependencies": {
"@google/genai": "^1.49.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"ai": "^6.0.97",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
@@ -34,11 +30,8 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"ws": "^8.21.0",
"xlsx": "^0.18.5",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
-11081
View File
File diff suppressed because it is too large. Load diff
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+4 -4
View File
@@ -51,10 +51,10 @@ export async function listCadences(): Promise<CadenceView[]> {
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map<string, string>();
try {
[rows, receptions] = await Promise.all([
db.select().from(commandeCadences),
getDerniereReceptionCached(),
]);
rows = await db.select().from(commandeCadences);
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
// l'historique de tous les fournisseurs était la plus lourde de la page.
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
} catch (e) {
console.error("[cadence] listCadences error:", (e as Error).message);
return [];
-25
View File
@@ -1,25 +0,0 @@
"use server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
export async function initSnapshotTable() {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
return { success: true };
} catch (err) {
console.error("Init DB error:", err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
-23
View File
@@ -2,9 +2,7 @@
import { getSitesFromApi } from "@/lib/api-ff-client";
import { getFournisseursCached } from "@/lib/ff-cache";
import { getProductRows } from "./api/get-product-rows";
import { requireSession } from "@/lib/authz";
import type { ProductRow, GridFilters } from "@/types/grid";
/**
* Get the list of all suppliers from PostgreSQL (fouadr1).
@@ -23,24 +21,3 @@ export async function getMagasins() {
await requireSession();
return getSitesFromApi();
}
/**
* Nomenclature filter is disabled — code3 is not available from the API.
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
await requireSession();
return {};
}
/**
* Get product data for a specific supplier and store.
*/
export async function getGridData(
codeFournisseur: string,
magasin: string = "TOTAL",
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
await requireSession();
return getProductRows({ codeFournisseur, magasin, filters });
}
@@ -62,8 +62,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
+39 -11
View File
@@ -11,6 +11,7 @@ import {
ColumnDef,
SortingState,
RowSelectionState,
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, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
@@ -46,6 +47,7 @@ import {
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
@@ -62,6 +64,39 @@ interface HeatmapGridProps {
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
/*
* Valeurs dérivées calculées une fois par ligne. Une ligne reçue ne change plus
* (une modification produit un nouvel objet) : on peut les garder tant qu'elle
* existe, sans rien invalider. Avant, la tendance était recalculée à chaque
* rendu de chaque case, et le texte de recherche à chaque frappe.
*/
const tendances = new WeakMap<ProductRow, NetworkTrend>();
function tendanceDe(row: ProductRow): NetworkTrend {
let t = tendances.get(row);
if (!t) {
t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
tendances.set(row, t);
}
return t;
}
const textesRecherche = new WeakMap<ProductRow, string>();
function texteRecherche(row: ProductRow): string {
let t = textesRecherche.get(row);
if (t === undefined) {
// Saut de ligne entre les champs : une recherche ne peut pas en contenir,
// donc aucune correspondance « à cheval » sur deux champs.
t = [row.libelle1, row.codein, row.reference, row.gtin].map((v) => String(v ?? "")).join("\n").toLowerCase();
textesRecherche.set(row, t);
}
return t;
}
/** Recherche sur libellé, code, référence et code-barres. */
const filtreRecherche: FilterFn<ProductRow> = (row, _columnId, valeur: string) => texteRecherche(row.original).includes(valeur);
// Saisie mise en minuscules une seule fois par frappe, pas une fois par ligne.
filtreRecherche.resolveFilterValue = (valeur) => String(valeur ?? "").toLowerCase();
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"caReseau",
"qteReseau",
@@ -528,7 +563,7 @@ interface CellDetailData {
* d'identité — sans quoi la même couleur voudrait dire deux choses.
*/
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const trend = tendanceDe(row);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
@@ -968,7 +1003,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
{
id: "tendanceReseau",
accessorFn: (row) => {
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const t = tendanceDe(row);
if (!t.hasData) return Number.NEGATIVE_INFINITY;
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
// forte progression possible : il doit remonter en tête du tri, pas
@@ -979,7 +1014,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 78,
cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
const trend = tendanceDe(row.original);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
@@ -1227,14 +1262,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
// colonne filtrable (une quinzaine) à chaque frappe.
getColumnCanGlobalFilter: (column) => column.id === "codein",
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase();
const libelle = String(row.original.libelle1 || "").toLowerCase();
const code = String(row.original.codein || "").toLowerCase();
const reference = String(row.original.reference || "").toLowerCase();
const gtin = String(row.original.gtin || "").toLowerCase();
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
},
globalFilterFn: filtreRecherche,
});
const { rows: tableRows } = table.getRowModel();
@@ -7,13 +7,13 @@
* produit (`/produits`). Aucun changement de comportement.
*/
import { useState } from "react";
import { memo, useState } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
function TrendSparklineInner({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct, nouveau } = trend;
const color = TREND_COLOR[direction];
@@ -51,6 +51,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
);
}
/** Mémorisée : la tendance d'une ligne est calculée une fois et ne change plus. */
export const TrendSparkline = memo(TrendSparklineInner);
/**
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
+26 -33
View File
@@ -1,36 +1,32 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/** Modifications portant sur les lignes chargées (le fournisseur affiché). */
function modificationsActives(draftChanges: Record<string, GammeCode>, rowsByCodein: Record<string, unknown>) {
return Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => rowsByCodein[codein]));
}
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Filters changes to only include those in the provided codeins list (e.g. current supplier).
* Enregistre les gammes modifiées du fournisseur affiché (server action).
*
* Le composant ne s'abonne qu'au NOMBRE de modifications : l'abonnement aux
* modifications elles-mêmes le redessinait à chaque changement de gamme. Le
* détail est lu au moment d'enregistrer.
*/
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const draftChanges = useGridStore((s) => s.draftChanges);
const rowsByCodein = useGridStore((s) => s.rowsByCodein);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const clearDrafts = useGridStore((s) => s.clearDrafts);
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
// Only consider changes that are in the filter list (if provided)
const filterCodeinSet = useMemo(
() => filterCodeins ? new Set(filterCodeins) : null,
[filterCodeins]
);
const activeDrafts = useMemo(
() => filterCodeinSet
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein)))
: draftChanges,
[draftChanges, filterCodeinSet]
);
const count = Object.keys(activeDrafts).length;
export function useSaveDrafts(magasin: string) {
const count = useGridStore((s) => {
let n = 0;
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return n;
});
const save = useCallback(async () => {
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
@@ -44,25 +40,22 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
if (changes.length === 0) return { success: true, saved: 0 };
// Derive supplier info from the first matching row
const firstRow = changes.length > 0 ? rowsByCodein[changes[0].codein] : undefined;
// Fournisseur déduit de la première ligne modifiée
const firstRow = rowsByCodein[changes[0].codein];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
if (result.success) {
// Apply saved values onto row.codeGamme BEFORE clearing drafts
// so that isModified (codeGamme !== codeGammeInit) stays true
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
// modifications, pour que l'indicateur « modifié » (codeGamme !==
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
if (filterCodeins) {
clearDrafts(Object.keys(activeDrafts));
} else {
resetDrafts();
}
clearDrafts(Object.keys(activeDrafts));
}
return result;
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
}, [magasin]);
return { save, hasDrafts: count > 0, count };
}
@@ -2,6 +2,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createThrottledJSONStorage } from "@/lib/throttled-json-storage";
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch";
import { getMonthsFromRows } from "@/features/grid/lib/months";
@@ -317,6 +318,9 @@ export const useGridStore = create<GridState>()(
}),
{
name: "collectflow-grid-storage",
// Écriture différée (500 ms) : `persist` réécrivait le localStorage à
// chaque changement d'état, y compris pendant un redimensionnement.
storage: createThrottledJSONStorage<GridPersisted>(500),
/**
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
@@ -1,27 +0,0 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
/**
* Score Settings Store — v4 (simplifié)
*
* Le score hybride v4 utilise des paliers absolus (Volume 50pts + CA 30pts + Marge 20pts + Bonus ±10pts).
* Il n'y a plus de pondérations configurables par l'utilisateur.
* Ce store est conservé pour la compatibilité mais n'a plus de paramètres ajustables.
*/
interface ScoreSettingsState {
_version: number;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
() => ({
_version: 4,
}),
{
name: "collectflow-score-settings",
}
)
);
-115
View File
@@ -291,52 +291,6 @@ export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string }
};
}
/** "2025-03-15" → "202503" */
export function dateToYYYYMM(isoDate: string): string {
return isoDate.slice(0, 7).replace("-", "");
}
// ---------------------------------------------------------------------------
// Fournisseurs
// ---------------------------------------------------------------------------
/** Extrait le code fournisseur depuis n'importe quelle forme de réponse API */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouCode(f: any): string {
return f.codefou ?? f.CodeFou ?? f.code_fournisseur ?? f.codeFournisseur ?? f.code ?? String(f.id ?? "");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouNom(f: any): string {
return f.nomfou ?? f.NomFou ?? f.nom_fournisseur ?? f.nomFournisseur ?? f.nom ?? f.libelle ?? f.name ?? "Inconnu";
}
export async function getFournisseursFromApi(
search?: string
): Promise<{ code: string; nom: string }[]> {
const FF_API_BASE = await getFfApiBase();
try {
const url = search
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
console.log(`[api-ff] GET ${url}`);
const res = await fetch(url, ffFetchInit());
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
const data = await res.json();
// Log premier item pour diagnostic
const rawList = extractList(data);
console.log(`[api-ff] fournisseurs: ${rawList.length} items, sample keys:`, rawList[0] ? Object.keys(rawList[0] as object) : "empty");
return rawList
.map((f: unknown) => ({ code: extractFouCode(f), nom: extractFouNom(f) }))
.filter((f: { code: string; nom: string }) => f.code);
} catch (err) {
console.error("[api-ff] getFournisseursFromApi error:", err);
return [];
}
}
// ---------------------------------------------------------------------------
// Articles
// ---------------------------------------------------------------------------
@@ -370,63 +324,6 @@ export async function getArticlesByFournisseur(
// Mouvements (ventes + stock12m)
// ---------------------------------------------------------------------------
export async function getMouvementsByFournisseur(
codefou: string,
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
/**
* Retourne les mouvements pour une plage de dates (sans filtre fournisseur).
* Utilisé pour extraire codefou_reel / nom_fournisseur_reel par codein.
*/
export async function getMouvementsForDate(
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
// ---------------------------------------------------------------------------
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
@@ -608,18 +505,6 @@ export async function getCommandesByFournisseur(
// Statut de synchronisation
// ---------------------------------------------------------------------------
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
try {
const FF_API_BASE = await getFfApiBase();
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
if (!res.ok) return null;
return normalizeSyncStatus(await res.json());
} catch (err) {
console.error("[api-ff] getSyncStatus error:", err);
return null;
}
}
// ---------------------------------------------------------------------------
// Sites (magasins)
// ---------------------------------------------------------------------------
+8 -5
View File
@@ -100,12 +100,15 @@ export function getFournisseursCached(): Promise<{ code: string; nom: string }[]
}
/**
* Dernière réception par fournisseur et magasin (cadencier). Agrège tout
* l'historique de `mvtart` : de loin la lecture la plus lourde de la page
* Commandes. Vide = erreur avalée, non gardé.
* Dernière réception par fournisseur et magasin, pour les fournisseurs du
* cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
* Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
* Vide = erreur avalée (ou aucune réception), non gardé.
*/
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
export function getDerniereReceptionCached(codes: string[]): Promise<Map<string, string>> {
const liste = [...new Set(codes.map((c) => c.trim()))].sort();
if (liste.length === 0) return Promise.resolve(new Map());
return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), {
cacheIf: (map) => map.size > 0,
});
}
-6
View File
@@ -58,9 +58,3 @@ export function trouverGamme(code: string | null | undefined): Gamme | undefined
if (!code) return undefined;
return GAMMES.find((g) => g.code === code.trim());
}
/** « A — Cœur », ou « Sans gamme ». */
export function libelleGamme(code: string | null | undefined): string {
const g = trouverGamme(code);
return g ? `${g.code} — ${g.nom}` : "Sans gamme";
}
+52 -73
View File
@@ -25,6 +25,28 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
// trois allers-retours de plus à chacune.
// ---------------------------------------------------------------------------
/**
* `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre
* (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à
* l'API FF d'un coup, qui la saturent et finissent en délais dépassés.
*/
async function parLots<T, R>(items: T[], simultanes: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const resultats: PromiseSettledResult<R>[] = new Array(items.length);
let suivant = 0;
const ouvrier = async () => {
while (suivant < items.length) {
const i = suivant++;
try {
resultats[i] = { status: "fulfilled", value: await fn(items[i]) };
} catch (reason) {
resultats[i] = { status: "rejected", reason };
}
}
};
await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier));
return resultats;
}
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
const r = await db.execute(query);
return { rows: r.rows };
@@ -156,55 +178,6 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
}
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
export interface PgArticleLocal {
codein: string;
codeCentrale: string;
libelle1?: string;
codefou?: string;
nomfou?: string;
pa?: number;
pv_central?: number;
}
/**
* Résout des codes centraux vers NOS articles locaux.
*
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
* absent de la Map = produit inconnu de FF Nancy.
*/
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.artcentrale)
TRIM(a.codein::text) AS codein,
a.artcentrale AS "codeCentrale",
a.libelle1,
af.code AS codefou,
fi.nom AS nomfou,
pa.pa,
ai.prix_vente_mini AS pv_central
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY a.artcentrale, af.no_id
`);
const map = new Map<string, PgArticleLocal>();
for (const r of result.rows as unknown as PgArticleLocal[]) {
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
}
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
return map;
}
// ---------------------------------------------------------------------------
// 2. Données mensuelles (ventes + stock + réceptions)
// ---------------------------------------------------------------------------
@@ -1426,29 +1399,28 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0);
};
const details = await Promise.allSettled(
uniqueCodeFous.map(async codefou => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
})
);
// 6 fournisseurs à la fois : chacun peut coûter deux appels successifs.
const details = await parLots(uniqueCodeFous, 6, async (codefou) => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
});
// Map codefou → franco enrichi
const francoMap = new Map<string, number>();
@@ -1490,8 +1462,14 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
* (l'UI affichera alors « échéance inconnue »).
*/
export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string, string>> {
export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
// Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles,
// au lieu de tout l'historique de réceptions de tous les fournisseurs.
if (codes && codes.length === 0) return map;
const filtreFournisseurs = codes
? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})`
: sql``;
try {
const result = await pgNoParallel(sql`
SELECT
@@ -1504,6 +1482,7 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND m.datmvt IS NOT NULL
${filtreFournisseurs}
GROUP BY af.code, m.site
`);
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware";
/**
* Stockage `persist` de zustand qui n'écrit dans le localStorage qu'au plus une
* fois par `delaiMs`.
*
* `persist` réécrit (et resérialise) l'état à chaque `set`, quel que soit le
* champ modifié : deux fois par changement de gamme, et à chaque mouvement de
* souris pendant qu'on élargit une colonne. Ici, seule la dernière valeur est
* écrite, et elle l'est aussitôt quand la page est quittée ou masquée.
*/
export function createThrottledJSONStorage<S>(delaiMs = 500): PersistStorage<S> | undefined {
// Rendu serveur : même repli que zustand par défaut (rien n'est persisté).
if (typeof window === "undefined") return undefined;
const inner = createJSONStorage<S>(() => window.localStorage);
if (!inner) return undefined;
let enAttente: { name: string; value: StorageValue<S> } | null = null;
let minuteur: number | null = null;
const ecrire = () => {
if (minuteur !== null) {
clearTimeout(minuteur);
minuteur = null;
}
if (!enAttente) return;
const { name, value } = enAttente;
enAttente = null;
try {
inner.setItem(name, value);
} catch {
/* quota dépassé ou stockage bloqué : ignoré, comme avant */
}
};
window.addEventListener("pagehide", ecrire);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") ecrire();
});
return {
getItem: (name) => (enAttente?.name === name ? enAttente.value : inner.getItem(name)),
setItem: (name, value) => {
enAttente = { name, value };
if (minuteur === null) minuteur = window.setTimeout(ecrire, delaiMs);
},
removeItem: (name) => {
if (enAttente?.name === name) enAttente = null;
inner.removeItem(name);
},
};
}
-7
View File
@@ -1,7 +0,0 @@
next.config.ts(9,3): error TS2353: Object literal may only specify known properties, and 'eslint' does not exist in type 'NextConfig'.
src/features/grid/components/supplier-ai-context-modal.tsx(4,24): error TS2307: Cannot find module '@/components/ui/button' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(5,114): error TS2307: Cannot find module '@/components/ui/dialog' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(6,26): error TS2307: Cannot find module '@/components/ui/textarea' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(87,36): error TS7006: Parameter 'e' implicitly has an 'any' type.
src/lib/mock-data.ts(66,19): error TS2345: Argument of type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is not assignable to parameter of type 'ProductRow'.
Type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is missing the following properties from type 'ProductRow': code1, libelleNiveau1, code2, libelleNiveau2, and 2 more.