mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
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:
23 files changed
+324
-13441
No files matched your search
@@ -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 [];
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user