Files
CollectFlow/src/features/grid/store/use-grid-store.ts
T
Claude b373dabeaa Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes
- API /api/v1 : PUT /products/:codein/gamme et POST /gammes déposent une
  proposition (table gammes_a_valider) au lieu d'enregistrer. La Grille la
  charge comme modification non enregistrée (badge « API ») : Enregistrer
  la valide, Annuler la rejette. Lecture : champ gammeAValider, statut
  a_valider dans la réponse ; OpenAPI mise à jour.
- Meilleures ventes, Ventes par mois, Publicités : plus de largeur max
  centrée, les pages occupent toute la largeur.
- Meilleures ventes : colonne Référence (référence fournisseur), recherche
  et export Excel compris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 09:27:07 +00:00

410 lines
19 KiB
TypeScript

"use client";
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";
/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */
export interface RowsMeta {
key: string;
loadedAt: number;
/** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */
patchedStores: string[];
}
/** Gamme proposée par l'API, en attente de validation (cf. lib/gammes-a-valider). */
export interface GammeProposee {
gamme: string;
auteur: string | null;
/** Date ISO de la proposition. */
proposeeLe: string;
}
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
interface GridState {
/** Source data from server */
rows: ProductRow[];
/** Fast lookup by codein; not persisted */
rowsByCodein: Record<string, ProductRow>;
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
/**
* Propositions de l'API en attente pour le fournisseur affiché (codein →
* proposition). Non persisté : relu sur le serveur à chaque chargement.
*/
gammesAValider: Record<string, GammeProposee>;
/**
* Range les propositions de l'API et les reporte en modifications non
* enregistrées — sauf là où l'utilisateur a déjà sa propre modification, et
* sauf celles déjà connues (une proposition écartée à la main ne revient pas
* à chaque retour sur la Grille).
*/
chargerGammesAValider: (propositions: ReadonlyArray<{ codein: string } & GammeProposee>) => void;
/** Retire des propositions traitées (validées ou rejetées). */
retirerGammesAValider: (codeins: string[]) => void;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
activeGridQuery: string;
/**
* Compteur de demandes de rechargement forcé (ignore le cache serveur).
* Volontairement hors URL et non persisté : un paramètre `_refresh` dans
* l'URL restait mémorisé dans `activeGridQuery`, et chaque retour sur la
* Grille par le menu relançait un recalcul complet.
*/
refreshRequest: number;
requestRefresh: () => void;
/**
* Ce que contiennent `rows` et quand elles ont été chargées. Non persisté :
* sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels
* magasins ont déjà reçu leur complément.
*/
rowsMeta: RowsMeta | null;
setRowsMeta: (meta: RowsMeta | null) => void;
/**
* Applique le complément d'un magasin, s'il concerne encore les lignes
* affichées (`gen` = clé et date de chargement au moment de la demande).
*/
applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void;
/** Persisted column visibility state */
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
columnSizing: Record<string, number>;
/**
* Bloc des 12 colonnes mensuelles affiché ou non dans la Grille.
*
* Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas
* une manipulation, et l'état « bloc replié » doit survivre au renouvellement
* de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque
* mois, un drapeau ne change pas.
*/
showMonthlySales: boolean;
setShowMonthlySales: (visible: boolean) => void;
/** Bloc des deux colonnes de prix (PV moyen réseau, PV central) affiché ou non. */
showPrices: boolean;
setShowPrices: (visible: boolean) => void;
/** Currently displayed store: "TOTAL" | "292" | "579" */
activeMagasin: string;
setActiveMagasin: (code: string) => void;
// Actions
setRows: (rows: ProductRow[]) => void;
setDraftGamme: (codein: string, gamme: GammeCode) => void;
resetDrafts: () => void;
/** Clear specific draft changes by codein */
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
setActiveGridQuery: (query: string) => void;
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
batchSetDraftGamme: (changes: Record<string, GammeCode>) => void;
setColumnVisibility: (updater: Record<string, boolean> | ((old: Record<string, boolean>) => Record<string, boolean>)) => void;
setColumnSizing: (updater: Record<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void;
}
let summaryTimer: ReturnType<typeof setTimeout> | null = null;
/** Retire l'ancien paramètre `_refresh` (cache-buster) d'une query string mémorisée. */
function sansParamRefresh(query: string): string {
if (!query.includes("_refresh")) return query;
const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query);
params.delete("_refresh");
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function indexRows(rows: ProductRow[]): Record<string, ProductRow> {
const indexed: Record<string, ProductRow> = {};
for (const row of rows) indexed[row.codein] = row;
return indexed;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, magasin = "TOTAL"): GridSummary {
const active = rows.filter((r) => {
const g = drafts[r.codein] ?? r.codeGamme;
return g !== "Z";
});
const getCa = (r: ProductRow) => magasin === "TOTAL" ? (r.totalCa ?? 0) : (r.caByStore?.[magasin] ?? 0);
const getQte = (r: ProductRow) => magasin === "TOTAL" ? (r.totalQuantite ?? 0) : (r.quantiteByStore?.[magasin] ?? 0);
const getMarge = (r: ProductRow) => magasin === "TOTAL" ? (r.totalMarge ?? 0) : (r.margeByStore?.[magasin] ?? 0);
const totalCa = active.reduce((s, r) => s + getCa(r), 0);
const totalMarge = active.reduce((s, r) => s + getMarge(r), 0);
return {
totalProducts: rows.length,
totalRows: active.length,
totalQuantite: active.reduce((s, r) => s + getQte(r), 0),
totalCa,
totalMarge,
tauxMargeGlobal: totalCa > 0 ? (totalMarge / totalCa) * 100 : 0,
};
}
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
type GridPersisted = Pick<
GridState,
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" | "showPrices"
>;
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
rows: [],
rowsByCodein: {},
activeMagasin: "TOTAL",
draftChanges: {},
gammesAValider: {},
filters: {
magasin: null,
codeFournisseur: null,
code1: null,
code2: null,
code3: null,
codeGamme: null,
search: "",
},
summary: {
totalProducts: 0,
totalRows: 0,
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMargeGlobal: 0,
},
displayDensity: "normal",
activeGridQuery: "",
refreshRequest: 0,
rowsMeta: null,
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
showPrices: true,
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
setShowPrices: (visible) => set({ showPrices: visible }),
setActiveMagasin: (code) => {
set({ activeMagasin: code });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
setRows: (rows) => {
set({ rows, rowsByCodein: indexRows(rows) });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 120);
},
setDraftGamme: (codein, gamme) => {
const { rowsByCodein, draftChanges: oldDrafts } = get();
const originalRow = rowsByCodein[codein];
const originalGamme = originalRow?.codeGamme;
const draftChanges = { ...oldDrafts };
if (gamme === originalGamme) {
// If the new value matches initial, remove it from drafts
delete draftChanges[codein];
} else {
// Otherwise, record the change
draftChanges[codein] = gamme;
}
set({ draftChanges });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
chargerGammesAValider: (propositions) => {
const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
const gammesAValider: Record<string, GammeProposee> = {};
const nouvelles: Record<string, GammeCode> = {};
for (const p of propositions) {
const row = rowsByCodein[p.codein];
if (!row) continue;
gammesAValider[p.codein] = { gamme: p.gamme, auteur: p.auteur, proposeeLe: p.proposeeLe };
const dejaConnue = connues[p.codein]?.proposeeLe === p.proposeeLe;
if (!dejaConnue && draftChanges[p.codein] === undefined && p.gamme !== row.codeGamme) {
nouvelles[p.codein] = p.gamme as GammeCode;
}
}
set({ gammesAValider });
if (Object.keys(nouvelles).length > 0) get().batchSetDraftGamme(nouvelles);
},
retirerGammesAValider: (codeins) => {
const gammesAValider = { ...get().gammesAValider };
codeins.forEach((id) => delete gammesAValider[id]);
set({ gammesAValider });
},
resetDrafts: () => {
set({ draftChanges: {} });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, {}, state.activeMagasin) });
}, 80);
},
clearDrafts: (codeins) => {
const draftChanges = { ...get().draftChanges };
codeins.forEach((id) => delete draftChanges[id]);
set({ draftChanges });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
/** Apply saved drafts onto row.codeGamme so isModified works after clearing drafts */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => {
const rows = get().rows.map(r => {
const newGamme = draftsToApply[r.codein];
if (newGamme !== undefined) {
return { ...r, codeGamme: newGamme };
}
return r;
});
set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
},
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
setCode3Filter: (codes) => {
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
setRowsMeta: (meta) => set({ rowsMeta: meta }),
applyStorePatch: (gen, patch) => {
const { rows, rowsByCodein, rowsMeta } = get();
if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return;
const patchedStores = rowsMeta.patchedStores.includes(patch.magasin)
? rowsMeta.patchedStores
: [...rowsMeta.patchedStores, patch.magasin];
// Complément calculé sur une autre fenêtre de 12 mois (changement de
// mois entre les deux requêtes) : on le laisse de côté.
const mois = getMonthsFromRows(rows);
const memeFenetre = !mois || mois.join(",") === patch.periods.join(",");
const parCode = new Map(patch.entries.map((e) => [e.codein, e]));
if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) {
set({ rowsMeta: { ...rowsMeta, patchedStores } });
return;
}
// Nouvelles lignes pour les seuls articles complétés : les autres
// gardent leur objet, et donc leur rendu mémorisé.
const nouvelles = rows.map((r) => {
const e = parCode.get(r.codein);
return e ? withStorePatch(r, patch.magasin, e) : r;
});
set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
restoreSnapshot: (changes) => {
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
},
batchSetDraftGamme: (newChanges) => {
const { rows, rowsByCodein, draftChanges: oldDrafts } = get();
const updatedDrafts = { ...oldDrafts };
Object.entries(newChanges).forEach(([codein, gamme]) => {
const originalRow = rowsByCodein[codein];
const originalGamme = originalRow?.codeGamme;
if (gamme === originalGamme) {
delete updatedDrafts[codein];
} else {
updatedDrafts[codein] = gamme;
}
});
set({ draftChanges: updatedDrafts });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
setColumnVisibility: (updater) => {
set((state) => ({
columnVisibility: typeof updater === 'function' ? updater(state.columnVisibility) : updater
}));
},
setColumnSizing: (updater) => {
set((state) => ({
columnSizing: typeof updater === 'function' ? updater(state.columnSizing) : updater
}));
},
}),
{
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
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
* apparaît vide. Le numéro de version force la reprise de l'existant.
*
* v2 — `activeGridQuery` pouvait contenir `_refresh=…` : le lien « Grille »
* du menu forçait alors un recalcul serveur complet à chaque retour.
*/
version: 2,
migrate: (persisted: unknown, version: number): GridPersisted => {
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
if (version < 1 && etat.filters) {
const c = etat.filters.code3;
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
}
// Drapeaux ajoutés après coup : un état persisté antérieur n'en a
// pas, et une valeur `undefined` écraserait la valeur par défaut
// à la fusion (le bloc disparaîtrait sans raison).
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
if (typeof etat.activeGridQuery === "string") {
etat.activeGridQuery = sansParamRefresh(etat.activeGridQuery);
}
return etat;
},
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
partialize: (state): GridPersisted => ({
filters: state.filters,
displayDensity: state.displayDensity,
draftChanges: state.draftChanges,
activeGridQuery: state.activeGridQuery,
columnVisibility: state.columnVisibility,
columnSizing: state.columnSizing,
showMonthlySales: state.showMonthlySales,
showPrices: state.showPrices,
}),
}
)
);