diff --git a/src/features/grid/api/enregistrer-gammes.ts b/src/features/grid/api/enregistrer-gammes.ts index 8c09861..84e4aa5 100644 --- a/src/features/grid/api/enregistrer-gammes.ts +++ b/src/features/grid/api/enregistrer-gammes.ts @@ -13,6 +13,25 @@ export interface ChangementGamme { codeGamme: string; } +/** Gammes d'un snapshot : codein → gamme avant (état FF) / après. */ +export type GammesSnapshot = Record; + +/** + * Dernier snapshot du fournisseur — celui que la Grille réapplique à chaque calcul + * (Phase 9). `null` s'il n'y en a aucun ; lève une erreur si la lecture échoue. + */ +export async function lireDernierSnapshot( + codeFournisseur: string, +): Promise<{ id: number; changes: GammesSnapshot } | null> { + const [dernier] = await db + .select({ id: sessionSnapshots.id, changes: sessionSnapshots.changes }) + .from(sessionSnapshots) + .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) + .orderBy(desc(sessionSnapshots.createdAt)) + .limit(1); + return dernier ? { id: dernier.id, changes: (dernier.changes ?? {}) as GammesSnapshot } : null; +} + export interface EnregistrementGammes { codeFournisseur: string; nomFournisseur?: string | null; @@ -35,17 +54,7 @@ export async function enregistrerGammes(input: EnregistrementGammes): Promise = - existing.length > 0 - ? (existing[0].changes as Record) - : {}; + const prevChanges = (await lireDernierSnapshot(codeFournisseur))?.changes ?? {}; // Merger les nouveaux changements par-dessus l'existant const mergedChanges = { ...prevChanges }; diff --git a/src/features/grid/api/lire-gammes-enregistrees.ts b/src/features/grid/api/lire-gammes-enregistrees.ts new file mode 100644 index 0000000..4ba707d --- /dev/null +++ b/src/features/grid/api/lire-gammes-enregistrees.ts @@ -0,0 +1,37 @@ +"use server"; + +import { z } from "zod"; +import { verifierSession } from "@/lib/authz"; +import { lireDernierSnapshot } from "./enregistrer-gammes"; + +/** + * Gammes enregistrées pour un fournisseur (dernier snapshot), quelle qu'en soit + * l'origine : la Grille, ou l'API /api/v1 pendant que la Grille était ouverte. + * + * `snapshotId` identifie l'état lu : `saveSnapshot` s'en sert pour reprendre les + * gammes enregistrées APRÈS cette lecture au lieu de les écraser. + */ +export async function lireGammesEnregistrees( + raw: unknown, +): Promise< + | { success: true; snapshotId: number | null; gammes: Record } + | { success: false; error: string } +> { + const acces = await verifierSession(); + if (!acces.ok) return { success: false, error: acces.message }; + + const parsed = z.string().min(1).safeParse(raw); + if (!parsed.success) return { success: false, error: "Code fournisseur manquant." }; + + try { + const dernier = await lireDernierSnapshot(parsed.data); + const gammes: Record = {}; + for (const [codein, delta] of Object.entries(dernier?.changes ?? {})) { + if (delta?.after) gammes[codein] = delta.after; + } + return { success: true, snapshotId: dernier?.id ?? null, gammes }; + } catch (err) { + console.error("[lireGammesEnregistrees]", err instanceof Error ? err.message : String(err)); + return { success: false, error: "lecture des gammes enregistrées impossible" }; + } +} diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index aa3b854..908b2cd 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -27,8 +27,11 @@ import { exporterTousProduits, imprimerPdf, lignesModifiees, + synchroniserGammes, } from "@/features/grid/lib/exports"; +type Synchro = Awaited>; + function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) { return (
@@ -101,33 +104,53 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean } }; + /** + * Gammes enregistrées depuis le chargement (API /api/v1, autre onglet) : l'écran + * les reprend avant tout export, sinon le fichier et l'Historique partaient de + * gammes périmées — et la copie dans l'Historique les effaçait. + */ + const alignerSurServeur = async (): Promise => { + if (!supplierCode) return { snapshotId: null, misesAJour: 0 }; + const synchro = await synchroniserGammes(supplierCode); + if (synchro.misesAJour > 0) { + const n = synchro.misesAJour; + toast.info(`${n} gamme${n > 1 ? "s ont" : " a"} été modifiée${n > 1 ? "s" : ""} entre-temps côté serveur (API) : l'écran a été mis à jour.`); + } + return synchro; + }; + /** * Enregistre une copie de la session dans l'Historique. * Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé). + * `synchro` : alignement déjà fait par l'appelant, sinon il est fait ici. */ - const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => { + const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot", synchro?: Synchro) => { if (rows.length === 0) return null; - const { draftChanges } = useGridStore.getState(); - // Tous les changements : brouillons + gammes déjà enregistrées (≠ FF) - const changes = Object.fromEntries( - lignesModifiees().map(r => [ - r.codein, - { before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string }, - ]) - ); try { + const { snapshotId: baseSnapshotId } = synchro ?? await alignerSurServeur(); + // Relu après l'alignement : les lignes et les totaux ont pu changer. + const { draftChanges, rows: lignes, summary: totaux } = useGridStore.getState(); + if (lignes.length === 0) return null; + // Tous les changements : brouillons + gammes déjà enregistrées (≠ FF) + const changes = Object.fromEntries( + lignesModifiees().map(r => [ + r.codein, + { before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string }, + ]) + ); const res = await saveSnapshot({ - codeFournisseur: filterFournisseur || rows[0].codeFournisseur, - nomFournisseur: rows[0].nomFournisseur, + codeFournisseur: filterFournisseur || lignes[0].codeFournisseur, + nomFournisseur: lignes[0].nomFournisseur, magasin: activeMagasin || "TOTAL", - label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, + label: labelOverride || `Session ${lignes[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, changes, type, + baseSnapshotId, summary: { - totalRows: summary.totalRows, - totalCa: summary.totalCa, - totalMarge: summary.totalMarge, - tauxMargeGlobal: summary.tauxMargeGlobal, + totalRows: totaux.totalRows, + totalCa: totaux.totalCa, + totalMarge: totaux.totalMarge, + tauxMargeGlobal: totaux.tauxMargeGlobal, }, }); if (!res.success) throw new Error(res.error); @@ -139,12 +162,12 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean } }; - /** Lance un export avec indicateur et messages d'erreur communs. */ - const lancer = async (cle: string, action: () => Promise, vide: string) => { + /** Lance un export (écran aligné sur le serveur) avec indicateur et messages d'erreur communs. */ + const lancer = async (cle: string, action: (synchro: Synchro) => Promise, vide: string) => { if (enCours) return; setEnCours(cle); try { - const ok = await action(); + const ok = await action(await alignerSurServeur()); if (!ok) toast.info(vide); } catch (err) { toast.erreur(`L'export a échoué : ${messageErreur(err)}`); @@ -189,11 +212,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean Fichier d'import des gammes lancer("modifs", async () => { + onSelect={() => lancer("modifs", async (synchro) => { if (lignesModifiees().length === 0) return false; const nom = lignesModifiees()[0].nomFournisseur; // Copie dans l'Historique, onglet « Exports » - await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export"); + await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export", synchro); return exporterFichierGammes(true); }, "Aucune gamme n'a été modifiée par rapport à FF.")} > diff --git a/src/features/grid/lib/exports.ts b/src/features/grid/lib/exports.ts index c03542d..9611105 100644 --- a/src/features/grid/lib/exports.ts +++ b/src/features/grid/lib/exports.ts @@ -3,10 +3,12 @@ * * Il y avait deux menus d'export (en-tête et barre du bas) aux droits différents. * Les bibliothèques lourdes (exceljs, jspdf) ne sont chargées qu'au clic. - * Les lignes sont lues dans le store au moment de l'export. + * Les lignes sont lues dans le store au moment de l'export, après + * `synchroniserGammes` : l'API peut avoir changé des gammes depuis le chargement. */ import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { lireGammesEnregistrees } from "@/features/grid/api/lire-gammes-enregistrees"; import type { ProductRow } from "@/types/grid"; function telecharger(blob: Blob, nomFichier: string) { @@ -25,6 +27,20 @@ function gammeEffective(r: ProductRow, drafts: Record): string | return (drafts[r.codein] ?? r.codeGamme) as string | null; } +/** + * Aligne les lignes affichées sur les gammes enregistrées côté serveur, sans + * recharger toute la Grille. À appeler avant un export ou une copie dans + * l'Historique. `snapshotId` est à transmettre à `saveSnapshot`. + */ +export async function synchroniserGammes( + codeFournisseur: string, +): Promise<{ snapshotId: number | null; misesAJour: number }> { + const res = await lireGammesEnregistrees(codeFournisseur); + if (!res.success) throw new Error(res.error); + const misesAJour = useGridStore.getState().applyServerGammes(res.gammes); + return { snapshotId: res.snapshotId, misesAJour }; +} + /** Lignes dont la gamme diffère de la gamme enregistrée dans FF. */ export function lignesModifiees(): ProductRow[] { const { rows, draftChanges } = useGridStore.getState(); diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index ebe4eee..96978d4 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -80,6 +80,8 @@ interface GridState { clearDrafts: (codeins: string[]) => void; /** Apply saved drafts onto row.codeGamme so change indicators persist after save */ applyDraftsToRows: (draftsToApply: Record) => void; + /** Reporte les gammes enregistrées côté serveur ; renvoie le nombre de lignes changées. */ + applyServerGammes: (gammes: Record) => number; setFilter: (key: Exclude, value: string | null) => void; /** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */ setCode3Filter: (codes: string[] | null) => void; @@ -240,6 +242,33 @@ export const useGridStore = create()( }); set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, + /** + * Gammes enregistrées hors de cet écran (API /api/v1, autre onglet) : les + * lignes prennent la valeur qu'un rechargement leur donnerait. Une + * modification en cours qui tombe sur cette valeur n'a plus lieu d'être. + */ + applyServerGammes: (gammes) => { + const { rows, draftChanges, activeMagasin } = get(); + const changees: string[] = []; + const next = rows.map((r) => { + const gamme = gammes[r.codein]; + if (gamme === undefined || gamme === r.codeGamme) return r; + changees.push(r.codein); + return { ...r, codeGamme: gamme as GammeCode }; + }); + if (changees.length === 0) return 0; + const drafts = { ...draftChanges }; + for (const codein of changees) { + if (drafts[codein] === gammes[codein]) delete drafts[codein]; + } + set({ + rows: next, + rowsByCodein: indexRows(next), + draftChanges: drafts, + summary: computeSummary(next, drafts, activeMagasin), + }); + return changees.length; + }, setFilter: (key, value) => { set((state) => ({ ...state, filters: { ...state.filters, [key]: value } })); }, diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index 81445fc..764fab5 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { sql, and, eq } from "drizzle-orm"; import { verifierSession } from "@/lib/authz"; import { revalidatePath } from "next/cache"; +import { lireDernierSnapshot, type GammesSnapshot } from "@/features/grid/api/enregistrer-gammes"; const SaveSnapshotSchema = z.object({ codeFournisseur: z.string(), @@ -17,6 +18,11 @@ const SaveSnapshotSchema = z.object({ after: z.string(), })), type: z.enum(["snapshot", "export"]).optional(), + /** + * Snapshot sur lequel l'écran s'est aligné juste avant (cf. lireGammesEnregistrees), + * `null` s'il n'y en avait aucun. Absent : tout le dernier snapshot est repris. + */ + baseSnapshotId: z.number().int().nullable().optional(), summary: z.object({ totalRows: z.number(), totalCa: z.number(), @@ -35,7 +41,7 @@ export async function saveSnapshot(raw: unknown) { return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") }; } - const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data; + const { codeFournisseur, nomFournisseur, magasin, label, summary, type, baseSnapshotId } = parsed.data; const rawUserId = acces.utilisateur.id; const userId = rawUserId ? parseInt(rawUserId, 10) : null; @@ -43,6 +49,16 @@ export async function saveSnapshot(raw: unknown) { const finalUserId = (userId && !isNaN(userId)) ? userId : null; + // Ce snapshot devient le dernier, celui que la Grille réapplique : il ne doit + // pas effacer les gammes enregistrées depuis que l'écran s'est aligné (API). + let changes = parsed.data.changes; + try { + changes = await reprendreGammesRecentes(codeFournisseur, changes, baseSnapshotId); + } catch (err) { + // Table absente ou base indisponible : l'insertion ci-dessous le dira. + console.error("[saveSnapshot] lecture du dernier snapshot KO:", err); + } + try { const [created] = await db .insert(sessionSnapshots) @@ -112,3 +128,34 @@ export async function saveSnapshot(raw: unknown) { } } } + +/** + * Ajoute aux gammes de l'écran celles enregistrées depuis le snapshot `baseSnapshotId` + * (sur lequel l'écran s'est aligné) : ce sont des écritures que l'écran n'a pas vues, + * typiquement par l'API. Les autres gammes du dernier snapshot, l'écran les + * connaissait : son état (retours à la gamme FF compris) fait foi. + */ +async function reprendreGammesRecentes( + codeFournisseur: string, + changes: GammesSnapshot, + baseSnapshotId: number | null | undefined, +): Promise { + const dernier = await lireDernierSnapshot(codeFournisseur); + if (!dernier || dernier.id === baseSnapshotId) return changes; + + let base: GammesSnapshot = {}; + if (baseSnapshotId != null) { + const [ligne] = await db + .select({ changes: sessionSnapshots.changes }) + .from(sessionSnapshots) + .where(and(eq(sessionSnapshots.id, baseSnapshotId), eq(sessionSnapshots.codeFournisseur, codeFournisseur))) + .limit(1); + base = (ligne?.changes ?? {}) as GammesSnapshot; + } + + const fusion = { ...changes }; + for (const [codein, delta] of Object.entries(dernier.changes)) { + if (delta?.after && delta.after !== base[codein]?.after) fusion[codein] = delta; + } + return fusion; +}