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

+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",
}
)
);