mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +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
@@ -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 }
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user