"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; /** Draft edits: codein → new GammeCode */ draftChanges: Record; /** * Propositions de l'API en attente pour le fournisseur affiché (codein → * proposition). Non persisté : relu sur le serveur à chaque chargement. */ gammesAValider: Record; /** * 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; /** Persisted column sizing state */ columnSizing: Record; /** * 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) => void; setFilter: (key: Exclude, 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) => void; batchSetDraftGamme: (changes: Record) => void; setColumnVisibility: (updater: Record | ((old: Record) => Record)) => void; setColumnSizing: (updater: Record | ((old: Record) => Record)) => void; } let summaryTimer: ReturnType | 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 { const indexed: Record = {}; for (const row of rows) indexed[row.codein] = row; return indexed; } function computeSummary(rows: ProductRow[], drafts: Record, 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()( 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 = {}; const nouvelles: Record = {}; 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) => { 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(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 }; 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, }), } ) );