Lot 5 : changement de magasin instantané et contrôle des droits

Grille
- Corrige le lot 4 : au changement de magasin, les chiffres et le tri
  affichés suivent tout de suite (nouvelle liste), la sélection est gardée.
- Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans
  aller-retour serveur (URL mise à jour par l'historique du navigateur).
- Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch),
  appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts).
- La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la
  sélection n'est remise à zéro qu'au changement de fournisseur.

Sécurité
- lib/authz.ts : session relue en base (cache 1 min), rôle de la base
  prioritaire ; variantes pour actions, routes et pages.
- Paramètres réservés aux administrateurs ; session vérifiée dans les actions
  Grille, Commandes, Historique et les routes ; diagnostics réservés admin.
- Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de
  passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement
  de la base n'efface plus les clés d'IA.
- admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé
  uniquement si la base est injoignable ; data/ ignoré par git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-04 06:39:49 +00:00
1 parent b7ee413d67
commit 26b9364232
44 files changed
+1155 -531

No files matched your search

+6 -14
View File
@@ -11,18 +11,10 @@
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { requireAdmin } from "@/lib/authz";
import { generateApiKey } from "@/lib/api-auth";
import { revalidatePath } from "next/cache";
async function ensureAdmin() {
const session = await auth();
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
throw new Error("Accès refusé : Droits administrateur requis.");
}
return session;
}
export interface ApiKeyRow {
id: number;
name: string;
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise<ApiKeyRow[]> {
await ensureAdmin();
await requireAdmin();
const rows = await db
.select({
id: apiKeys.id,
@@ -67,7 +59,7 @@ export async function createApiKey(
name: string,
role: "admin" | "user" = "user",
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
const session = await ensureAdmin();
const moi = await requireAdmin();
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
@@ -80,7 +72,7 @@ export async function createApiKey(
keyPrefix,
keyHash,
role,
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
createdBy: moi.username || null,
});
revalidatePath("/settings");
return { success: true, key, keyPrefix };
@@ -92,7 +84,7 @@ export async function createApiKey(
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
await requireAdmin();
try {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
revalidatePath("/settings");
@@ -105,7 +97,7 @@ export async function revokeApiKey(id: number): Promise<{ success: boolean; erro
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
await requireAdmin();
try {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
revalidatePath("/settings");
+8 -20
View File
@@ -4,24 +4,14 @@ import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { hashPassword } from "@/features/auth/logic/auth-logic";
import { auth } from "@/lib/auth";
import { requireAdmin } from "@/lib/authz";
import { revalidatePath } from "next/cache";
/**
* Vérifie si l'utilisateur actuel est un administrateur.
*/
async function ensureAdmin() {
const session = await auth();
if ((session?.user as any)?.role !== "admin") {
throw new Error("Accès refusé : Droits administrateur requis.");
}
}
/**
* Récupère tous les utilisateurs (Admin seulement).
*/
export async function getUsers() {
await ensureAdmin();
await requireAdmin();
return db.select({
id: users.id,
username: users.username,
@@ -34,7 +24,7 @@ export async function getUsers() {
* Crée un nouvel utilisateur.
*/
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
await ensureAdmin();
await requireAdmin();
try {
await db.insert(users).values({
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
* Supprime un utilisateur.
*/
export async function deleteUser(id: number) {
await ensureAdmin();
const session = await auth();
if (Number((session?.user as any)?.id) === id) {
const moi = await requireAdmin();
if (Number(moi.id) === id) {
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
}
@@ -65,7 +53,7 @@ export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
} catch {
return { success: false, error: "Erreur lors de la suppression." };
}
}
@@ -74,13 +62,13 @@ export async function deleteUser(id: number) {
* Met à jour le mot de passe d'un utilisateur.
*/
export async function updatePassword(id: number, newPassword: string) {
await ensureAdmin();
await requireAdmin();
try {
await db.update(users)
.set({ passwordHash: hashPassword(newPassword) })
.where(eq(users.id, id));
return { success: true };
} catch (err) {
} catch {
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
}
}
+63 -41
View File
@@ -1,15 +1,72 @@
import { db } from "@/db";
import { users } from "@/db/schema";
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
import { count, eq, sql } from "drizzle-orm";
import { sql } from "drizzle-orm";
/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
let amorcage: Promise<boolean> | null = null;
/**
* Checks if the users table is empty.
* If so, creates a default admin account (admin/admin).
* Also ensures the table exists (auto-repair).
* Prépare les comptes au premier démarrage, une seule fois par processus.
*
* - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
* compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
* renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
* `data/users.json` n'est pas touché.
* - Base injoignable (première installation, base pas encore configurée) : on
* s'assure que le fichier de secours contient un administrateur, et on
* retentera la base à la prochaine connexion.
*
* Renvoie true si le compte admin/admin vient d'être créé en base.
*/
export async function ensureAdminExists() {
// 1. Always ensure local JSON fallback has at least one admin
export function ensureAdminExists(): Promise<boolean> {
if (!amorcage) {
amorcage = amorcer().catch((err: unknown) => {
// Échec : on retentera à la prochaine connexion.
amorcage = null;
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
return false;
});
}
return amorcage;
}
async function amorcer(): Promise<boolean> {
console.log("[AUTH] Testing database connection for seeding...");
try {
await db.execute(sql`SELECT 1`);
} catch (err) {
assurerAdminDeSecours();
throw err;
}
// Auto-repair: Ensure table exists
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
);
`);
const [{ nb }] = await db.select({ nb: sql<number>`count(*)::int` }).from(users);
if (nb > 0) return false;
console.log("[AUTH] Table users vide : création du compte admin/admin.");
// Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
// l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
const crees = await db.insert(users).values({
username: "admin",
passwordHash: hashPassword("admin"),
role: "admin",
}).onConflictDoNothing().returning({ id: users.id });
return crees.length > 0;
}
/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
function assurerAdminDeSecours() {
const fallbackUsers = getFallbackUsers();
if (fallbackUsers.length === 0) {
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
@@ -20,39 +77,4 @@ export async function ensureAdminExists() {
role: "admin"
}]);
}
try {
console.log("[AUTH] Testing database connection for seeding...");
await db.execute(sql`SELECT 1`);
// 2. Auto-repair: Ensure table exists
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
);
`);
// 3. Sync admin to DB
const adminUser = await db.select()
.from(users)
.where(eq(users.username, "admin"))
.limit(1);
if (adminUser.length === 0) {
console.log("[AUTH] Seeding admin to DB...");
await db.insert(users).values({
username: "admin",
passwordHash: hashPassword("admin"),
role: "admin"
});
return true;
}
} catch (err: any) {
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message);
}
return false;
}
+14 -2
View File
@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { commandeCadences } from "@/db/schema";
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
import { requireSession, verifierSession } from "@/lib/authz";
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
@@ -37,6 +38,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
await requireSession();
return getFournisseursCached();
}
@@ -45,6 +47,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
* de la dernière réception (FF Nancy).
*/
export async function listCadences(): Promise<CadenceView[]> {
await requireSession();
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map<string, string>();
try {
@@ -95,6 +98,9 @@ export interface UpsertCadenceInput {
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
const codefou = input.codefou?.trim();
const site = input.site?.trim();
const intervalle = Math.round(Number(input.intervalleSemaines));
@@ -132,7 +138,10 @@ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: bo
}
/** Active / désactive une cadence sans la supprimer. */
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> {
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
try {
await db
.update(commandeCadences)
@@ -147,7 +156,10 @@ export async function toggleCadence(codefou: string, site: string, actif: boolea
}
/** Supprime définitivement une cadence. */
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> {
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
try {
await db
.delete(commandeCadences)
+5
View File
@@ -3,12 +3,14 @@
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).
*/
export async function getFournisseurs() {
await requireSession();
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
return getFournisseursCached();
@@ -18,6 +20,7 @@ export async function getFournisseurs() {
* Get the list of all stores (sites) from the FF Nancy API.
*/
export async function getMagasins() {
await requireSession();
return getSitesFromApi();
}
@@ -26,6 +29,7 @@ export async function getMagasins() {
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
await requireSession();
return {};
}
@@ -37,5 +41,6 @@ export async function getGridData(
magasin: string = "TOTAL",
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
await requireSession();
return getProductRows({ codeFournisseur, magasin, filters });
}
+83 -94
View File
@@ -19,6 +19,13 @@ import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
import {
applyStorePatchInPlace,
buildStorePatchEntries,
storePatchCandidates,
type StorePatch,
type StorePatchEntry,
} from "@/features/grid/lib/store-patch";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -568,28 +575,27 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
}
}
async function reconcileSelectedStoreFromMensuelApi(
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
function plafondComplement(): number {
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
}
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
async function fetchStorePatchEntries(
rows: ProductRow[],
magasin: string,
dateDebut: string,
dateFin: string,
sortedPeriods: string[]
) {
if (magasin === "TOTAL") return;
const candidates = rows.filter((row) => {
if (!row.noid) return false;
const storeQty = row.quantiteByStore?.[magasin] ?? 0;
return storeQty === 0;
});
if (candidates.length === 0) return;
periods: string[],
): Promise<StorePatchEntry[]> {
const candidates = storePatchCandidates(rows, magasin);
if (candidates.length === 0) return [];
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
const PLAFOND = plafondComplement();
const retenus = candidates.slice(0, PLAFOND);
if (candidates.length > retenus.length) {
console.warn(
@@ -598,88 +604,31 @@ async function reconcileSelectedStoreFromMensuelApi(
);
}
const mensuelMap = await getMensuelByArticles(
retenus.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
noid: row.noid,
})),
dateDebut,
dateFin,
25
);
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
}
async function reconcileSelectedStoreFromMensuelApi(
rows: ProductRow[],
magasin: string,
dateDebut: string,
dateFin: string,
sortedPeriods: string[]
) {
if (magasin === "TOTAL") return;
try {
const mensuelMap = await getMensuelByArticles(
retenus.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
noid: row.noid,
})),
dateDebut,
dateFin,
25
);
let fixedRows = 0;
for (const row of retenus) {
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!sortedPeriods.includes(period)) continue;
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
byPeriod.set(period, { qty, ca, marge, stock, receptions });
}
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
if (apiQty === 0) continue;
row.sales12mByStore ??= {};
row.stock12mByStore ??= {};
row.receptions12mByStore ??= {};
row.caByStore ??= {};
row.quantiteByStore ??= {};
row.margeByStore ??= {};
row.sales12mByStore[magasin] ??= {};
row.stock12mByStore[magasin] ??= {};
row.receptions12mByStore[magasin] ??= {};
let storeQty = 0;
let storeCa = 0;
let storeMarge = 0;
let lastStock = 0;
for (const period of sortedPeriods) {
const value = byPeriod.get(period);
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
const nextQty = value?.qty ?? 0;
const deltaQty = nextQty - currentQty;
row.sales12mByStore[magasin][period] = nextQty;
row.receptions12mByStore[magasin][period] = value?.receptions ?? 0;
if (value) lastStock = value.stock;
row.stock12mByStore[magasin][period] = lastStock;
row.sales12m[period] = (row.sales12m[period] ?? 0) + deltaQty;
storeQty += nextQty;
storeCa += value?.ca ?? 0;
storeMarge += value?.marge ?? 0;
}
const deltaTotalQty = storeQty - (row.quantiteByStore[magasin] ?? 0);
const deltaTotalCa = storeCa - (row.caByStore[magasin] ?? 0);
const deltaTotalMarge = storeMarge - (row.margeByStore[magasin] ?? 0);
row.quantiteByStore[magasin] = storeQty;
row.caByStore[magasin] = storeCa;
row.margeByStore[magasin] = storeMarge;
row.totalQuantite += deltaTotalQty;
row.totalCa += deltaTotalCa;
row.totalMarge += deltaTotalMarge;
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
fixedRows++;
}
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
if (fixedRows > 0) {
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
}
@@ -688,6 +637,46 @@ async function reconcileSelectedStoreFromMensuelApi(
}
}
/**
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
* enregistrement), sans autre invalidation à gérer.
*/
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
/**
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
* un échec n'est jamais gardé.
*/
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
? enCache.rows
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
let parMagasin = storePatches.get(rows);
if (!parMagasin) {
parMagasin = new Map();
storePatches.set(rows, parMagasin);
}
let patch = parMagasin.get(magasin);
if (!patch) {
const periods = last12Periods();
const { dateDebut, dateFin } = buildLast12MonthsRange();
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
.then((entries): StorePatch => ({ magasin, periods, entries }));
const table = parMagasin;
enCours.catch(() => {
if (table.get(magasin) === enCours) table.delete(magasin);
});
parMagasin.set(magasin, enCours);
patch = enCours;
}
return patch;
}
/**
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
*
+6 -4
View File
@@ -4,7 +4,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { verifierSession } from "@/lib/authz";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
@@ -24,6 +24,9 @@ const SaveDraftsSchema = z.object({
export async function saveDraftChanges(
raw: unknown
): Promise<{ success: boolean; saved: number; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, saved: 0, error: acces.message };
const parsed = SaveDraftsSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
@@ -31,9 +34,8 @@ export async function saveDraftChanges(
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
const session = await auth();
const rawUserId = (session?.user as { id?: string | number })?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
const finalUserId = userId && !isNaN(userId) ? userId : null;
try {
+22 -14
View File
@@ -5,7 +5,8 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
import type { ProductRow } from "@/types/grid";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -61,6 +62,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const activeMagasin = useGridStore((s) => s.activeMagasin);
const searchParams = useSearchParams();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
@@ -107,18 +109,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const controller = new AbortController();
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
// Toujours « tous magasins » : les lignes portent le détail de chaque
// magasin, le changement de magasin se fait donc sans rien recharger
// (les compléments de l'API FF arrivent à part, cf. useStorePatch).
params.set("magasin", "TOTAL");
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
// voyager relançait un chargement complet à chaque changement.
const forceRefresh = refreshRequest !== servedRefreshRef.current;
if (forceRefresh) params.set("refresh", "1");
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
const cle = rowsKeyFor(codeFournisseur);
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
// Retour sur la Grille (même fournisseur, même magasin, chargée il y a
// moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
// Retour sur la Grille (même fournisseur, chargée il y a moins de 10 min) :
// les lignes sont encore en mémoire, rien à retélécharger.
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
setRowsLoaded(lignesEnMemoire.length);
@@ -127,11 +132,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return;
}
// Changement de magasin (ou rechargement) sur le même fournisseur : la
// Grille reste affichée — les lignes portent déjà le détail par magasin,
// la bascule est immédiate — et les chiffres complétés pour ce magasin
// remplacent l'affichage d'un bloc, une fois reçus.
const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
// « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la
// Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc.
const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0;
const accumulatedRows: ProductRow[] = [];
let lastFlush = 0;
@@ -210,7 +213,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
}
flush(loaded, total, true);
setRowsMeta({ key: cle, loadedAt: Date.now() });
setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] });
if (forceRefresh) servedRefreshRef.current = refreshRequest;
} catch (error) {
if (!controller.signal.aborted) {
@@ -226,9 +229,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
useStorePatch(codeFournisseur, isMounted);
// Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les
// changements suivants passent par le store et ne re-rendent pas la page.
useEffect(() => {
if (!isMounted) return;
setActiveMagasin(magasin || "TOTAL");
@@ -259,7 +266,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
</h1>
</div>
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
<MetricPill label="Magasin" value={nomMagasin(activeMagasin)} />
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
</div>
</div>
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
{/* Main content */}
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
codeFournisseur={codeFournisseur}
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
nomFournisseur={nomFournisseur}
@@ -97,11 +97,15 @@ function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const activeMagasin = useGridStore((s) => s.activeMagasin);
// Changement de magasin sans aller-retour serveur : les lignes portent déjà
// le détail par magasin. L'URL suit (lien partageable, retour par le menu),
// via l'historique du navigateur, que Next répercute sur useSearchParams.
const handleStoreSelect = (code: string) => {
if (code === activeMagasin) return;
setActiveMagasin(code);
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.replace(`/grid?${params.toString()}`, { scroll: false });
window.history.replaceState(null, "", `?${params.toString()}`);
};
+21 -9
View File
@@ -52,6 +52,8 @@ import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
interface HeatmapGridProps {
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
codeFournisseur: string;
onSelectionChange?: (codeins: string[]) => void;
isAdmin?: boolean;
/** Sert à nommer et documenter les extractions issues de la Grille. */
@@ -699,10 +701,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
// =========================================================================
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const code3 = useGridStore((s) => s.filters.code3);
const codeGamme = useGridStore((s) => s.filters.codeGamme);
const search = useGridStore((s) => s.filters.search);
const displayDensity = useGridStore((s) => s.displayDensity);
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
const activeMagasin = useGridStore((s) => s.activeMagasin);
@@ -713,12 +719,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
const { code3, codeGamme } = filters;
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
void activeMagasin;
// Garde : un état persisté d'une version antérieure peut encore contenir
// une chaîne. La migration du store le corrige, mais un `new Set("320211")`
// produirait un ensemble de caractères et viderait la Grille sans rien dire.
const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null;
if (!codes && !codeGamme) return rows;
if (!codes && !codeGamme) return rows.slice();
// Set plutôt que includes() : la liste peut compter des dizaines de
// nomenclatures, et le filtre est réévalué pour chaque ligne.
const nomenclatures = codes ? new Set(codes) : null;
@@ -731,7 +741,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
}
return true;
});
}, [rows, filters, draftChanges]);
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -775,10 +785,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
[]
);
// Reset selection when rows change (e.g. supplier change)
// Sélection remise à zéro au changement de fournisseur seulement : elle est
// indexée par code article, et survit donc à un rechargement des lignes, à un
// enregistrement ou au complément des chiffres d'un magasin.
useEffect(() => {
setRowSelection({});
}, [rows]);
}, [codeFournisseur]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
@@ -1198,7 +1210,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
const table = useReactTable({
data: filteredData,
columns,
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
@@ -1249,7 +1261,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
}, [table, rowSelection, onSelectionChange, filteredData, search]);
const rowVirtualizer = useVirtualizer({
@@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
import type { StorePatch } from "@/features/grid/lib/store-patch";
/**
* Demande le complément de l'API FF du magasin affiché, une fois les lignes
* chargées, et l'applique à son arrivée. Une seule demande par magasin et par
* chargement : revenir sur un magasin déjà complété ne coûte rien.
*/
export function useStorePatch(codeFournisseur: string, enabled: boolean) {
const activeMagasin = useGridStore((s) => s.activeMagasin);
const rowsMeta = useGridStore((s) => s.rowsMeta);
const applyStorePatch = useGridStore((s) => s.applyStorePatch);
useEffect(() => {
if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return;
if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return;
const controller = new AbortController();
const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt };
const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin });
fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" })
.then((res) => (res.ok ? (res.json() as Promise<StorePatch>) : null))
.then((patch) => {
if (patch && !controller.signal.aborted) applyStorePatch(gen, patch);
})
// Échec ou abandon : les chiffres SQL restent affichés, et la demande
// repartira au prochain passage sur ce magasin.
.catch(() => {});
return () => controller.abort();
}, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Complément des chiffres d'un magasin par l'API FF (ventes mensuelles).
*
* Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
* articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
* pour ces articles seulement, les séries et totaux du magasin.
*
* Module sans dépendance d'exécution : utilisé côté serveur (calcul du
* complément) comme côté navigateur (application aux lignes affichées).
*/
import type { ProductRow } from "@/types/grid";
import type { FfMensuelEntry } from "@/lib/api-ff-client";
/** Chiffres d'un article pour le magasin complété. */
export interface StorePatchEntry {
codein: string;
/** Ventes par mois (« YYYYMM »). */
sales: Record<string, number>;
/** Stock de fin de mois, reporté sur les mois sans donnée. */
stock: Record<string, number>;
receptions: Record<string, number>;
quantite: number;
ca: number;
marge: number;
}
export interface StorePatch {
magasin: string;
/** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */
periods: string[];
entries: StorePatchEntry[];
}
/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */
export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] {
return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0);
}
/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */
export function buildStorePatchEntries(
retenus: ProductRow[],
magasin: string,
periods: string[],
mensuel: Map<string, FfMensuelEntry[]>,
): StorePatchEntry[] {
const resultat: StorePatchEntry[] = [];
for (const row of retenus) {
const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!periods.includes(period)) continue;
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
byPeriod.set(period, { qty, ca, marge, stock, receptions });
}
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
if (apiQty === 0) continue;
const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 };
let lastStock = 0;
for (const period of periods) {
const value = byPeriod.get(period);
e.sales[period] = value?.qty ?? 0;
e.receptions[period] = value?.receptions ?? 0;
if (value) lastStock = value.stock;
e.stock[period] = lastStock;
e.quantite += value?.qty ?? 0;
e.ca += value?.ca ?? 0;
e.marge += value?.marge ?? 0;
}
resultat.push(e);
}
return resultat;
}
/**
* Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
* magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
* Renvoie le nombre de lignes corrigées.
*/
export function applyStorePatchInPlace(
rows: ProductRow[],
magasin: string,
periods: string[],
entries: StorePatchEntry[],
): number {
const parCode = new Map(entries.map((e) => [e.codein, e]));
let corrigees = 0;
for (const row of rows) {
const e = parCode.get(row.codein);
if (!e) continue;
row.sales12mByStore ??= {};
row.stock12mByStore ??= {};
row.receptions12mByStore ??= {};
row.caByStore ??= {};
row.quantiteByStore ??= {};
row.margeByStore ??= {};
row.sales12mByStore[magasin] ??= {};
row.stock12mByStore[magasin] ??= {};
row.receptions12mByStore[magasin] ??= {};
for (const period of periods) {
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
const nextQty = e.sales[period] ?? 0;
row.sales12mByStore[magasin][period] = nextQty;
row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0;
row.stock12mByStore[magasin][period] = e.stock[period] ?? 0;
row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty);
}
const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0);
const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0);
const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0);
row.quantiteByStore[magasin] = e.quantite;
row.caByStore[magasin] = e.ca;
row.margeByStore[magasin] = e.marge;
row.totalQuantite += deltaTotalQty;
row.totalCa += deltaTotalCa;
row.totalMarge += deltaTotalMarge;
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
corrigees++;
}
return corrigees;
}
/**
* Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls.
* Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2
* magasins » reste identique quel que soit le magasin consulté avant.
*/
export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow {
return {
...row,
sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales },
stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock },
receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions },
quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite },
caByStore: { ...row.caByStore, [magasin]: e.ca },
margeByStore: { ...row.margeByStore, [magasin]: e.marge },
workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin],
};
}
+51 -5
View File
@@ -3,6 +3,19 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
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[];
}
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
interface GridState {
/** Source data from server */
@@ -25,12 +38,17 @@ interface GridState {
refreshRequest: number;
requestRefresh: () => void;
/**
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
* revient, et à changer de magasin sans vider l'écran.
* 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: { key: string; loadedAt: number } | null;
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
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 */
@@ -231,6 +249,34 @@ export const useGridStore = create<GridState>()(
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) });
},
+129 -123
View File
@@ -1,43 +1,114 @@
"use server";
/**
* Actions des Paramètres — réservées aux administrateurs.
*
* Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et
* les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide
* dans le formulaire signifie « conserver celui qui est enregistré ».
*/
import { Pool } from "pg";
import fs from "fs/promises";
import path from "path";
import { requireAdmin, verifierAdmin } from "@/lib/authz";
import { CONFIG_FILE, readConfig, writeConfig } from "./config-file";
import { diagnostiquerApiFf } from "./ff-api-diagnostic";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface DbConfig {
url: string;
openRouterKey?: string;
openRouterModel?: string;
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
/**
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
* sans redéployer.
*/
ffApiBaseUrl?: string;
export interface ChampsBaseDeDonnees {
host: string;
port: string;
database: string;
user: string;
/** Vide ou absent : le mot de passe enregistré est conservé. */
password?: string;
ssl: boolean;
}
/** Lit la config existante (ou {} si absente). */
async function readConfig(): Promise<Partial<DbConfig>> {
/** Ce que le formulaire peut afficher : jamais de mot de passe ni de clé. */
export interface ParametresAffichables {
db: {
host: string;
port: string;
database: string;
user: string;
ssl: boolean;
motDePasseEnregistre: boolean;
} | null;
qlik: { host: string; user: string; motDePasseEnregistre: boolean };
ffApiBaseUrl: string;
}
/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */
function decoder(valeur: string): string {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
return decodeURIComponent(valeur);
} catch {
return {};
return valeur;
}
}
export async function testDatabaseConnection(url: string) {
/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */
function lireUrlPostgres(url: string) {
try {
const u = new URL(url);
return {
host: u.hostname,
port: u.port || "5432",
database: decoder(u.pathname.slice(1)),
user: decoder(u.username),
password: decoder(u.password),
ssl: url.includes("sslmode=require"),
};
} catch (e) {
console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message);
return null;
}
}
function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string {
const user = encodeURIComponent(champs.user.trim());
const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user;
const port = champs.port.trim() || "5432";
return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`;
}
/** Mot de passe saisi, sinon celui de l'URL enregistrée. */
async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise<string> {
if (champs.password) return champs.password;
const { url } = await readConfig();
return (url && lireUrlPostgres(url)?.password) || "";
}
/** Réglages enregistrés, sans aucun secret. */
export async function getParametresAffichables(): Promise<ParametresAffichables> {
await requireAdmin();
const config = await readConfig();
const base = config.url ? lireUrlPostgres(config.url) : null;
return {
db: base
? {
host: base.host,
port: base.port,
database: base.database,
user: base.user,
ssl: base.ssl,
motDePasseEnregistre: base.password !== "",
}
: null,
qlik: {
host: config.qlikHost ?? "",
user: config.qlikUser ?? "",
// Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement.
motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD),
},
ffApiBaseUrl: config.ffApiBaseUrl ?? "",
};
}
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
const pool = new Pool({
connectionString: url,
@@ -58,24 +129,15 @@ export async function testDatabaseConnection(url: string) {
}
}
export async function saveDatabaseSettings(
url: string,
openRouterKey?: string,
openRouterModel?: string,
aiProvider?: "openrouter" | "google",
googleAiKey?: string,
googleAiModel?: string,
) {
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
const existing = await readConfig();
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// S'assurer que le dossier data existe
await fs.mkdir(DATA_DIR, { recursive: true });
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
console.log(`[Settings] Saving config to ${CONFIG_FILE}`);
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ url });
console.log("[Settings] Database configuration saved successfully.");
// Refresh the shared DB instance immediately
@@ -90,12 +152,13 @@ export async function saveDatabaseSettings(
}
}
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const existing = await readConfig();
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) });
console.log("[Settings] Qlik configuration saved.");
return { success: true };
} catch (error: unknown) {
@@ -105,10 +168,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP
}
}
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
const enregistre = getQlikConfig();
const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password };
if (qlikHost) cfg.host = qlikHost;
await qlikNtlmSession(cfg);
return { success: true };
@@ -120,15 +188,15 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
export async function saveFfApiSettings(ffApiBaseUrl: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
}
const existing = await readConfig();
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ ffApiBaseUrl: cleaned || undefined });
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
resetFfApiBaseCache();
@@ -141,71 +209,9 @@ export async function saveFfApiSettings(ffApiBaseUrl: string) {
}
}
/**
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
*
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
*/
export async function testFfApiConnection(ffApiBaseUrl?: string) {
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
const url = `${base}/api/sync/status`;
const started = Date.now();
try {
const res = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
const ms = Date.now() - started;
if (!res.ok) {
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
}
const body = await res.json().catch(() => null);
if (!body) {
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
}
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
const status = normalizeSyncStatus(body);
if (!status) {
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
}
return { success: true, url, ms, status };
} catch (error: unknown) {
const ms = Date.now() - started;
const raw = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
let hint = raw;
if (name === "TimeoutError" || name === "AbortError") {
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
} else if (cause?.code === "ENOTFOUND") {
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
} else if (cause?.code === "ECONNREFUSED") {
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
} else if (cause?.code) {
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
}
console.error(`[Settings] FF API test KO (${url}):`, raw);
return { success: false, url, error: hint };
}
}
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
try {
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
return null;
}
const data = await fs.readFile(CONFIG_FILE, "utf-8");
const config = JSON.parse(data);
console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`);
return config;
} catch (error) {
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
return null;
}
/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<Awaited<ReturnType<typeof diagnostiquerApiFf>>> {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, url: "", error: acces.message };
return diagnostiquerApiFf(ffApiBaseUrl);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* CollectFlow — Fichier de configuration `data/.db-config.json`.
*
* Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être
* appelables depuis le navigateur (le fichier contient des mots de passe et des
* clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
export const DATA_DIR = path.join(process.cwd(), "data");
export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface DbConfig {
url: string;
openRouterKey?: string;
openRouterModel?: string;
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
/**
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
* sans redéployer.
*/
ffApiBaseUrl?: string;
}
/** Lit la config existante (ou {} si absente). */
export async function readConfig(): Promise<Partial<DbConfig>> {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
/**
* Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
* conservées telles quelles (une clé passée à `undefined` est retirée).
*/
export async function writeConfig(patch: Partial<DbConfig>): Promise<void> {
const existing = await readConfig();
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2));
}
@@ -0,0 +1,64 @@
/**
* CollectFlow — Diagnostic de l'API FF Nancy.
*
* Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
* route `/api/ff-status` (tout utilisateur connecté). Module serveur et non
* fichier d'actions : chaque appelant fait son propre contrôle d'accès.
*/
import "server-only";
import { readConfig } from "./config-file";
/**
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
*
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
*/
export async function diagnostiquerApiFf(ffApiBaseUrl?: string) {
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
const url = `${base}/api/sync/status`;
const started = Date.now();
try {
const res = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
const ms = Date.now() - started;
if (!res.ok) {
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
}
const body = await res.json().catch(() => null);
if (!body) {
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
}
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
const status = normalizeSyncStatus(body);
if (!status) {
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
}
return { success: true, url, ms, status };
} catch (error: unknown) {
const ms = Date.now() - started;
const raw = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
let hint = raw;
if (name === "TimeoutError" || name === "AbortError") {
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
} else if (cause?.code === "ENOTFOUND") {
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
} else if (cause?.code === "ECONNREFUSED") {
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
} else if (cause?.code) {
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
}
console.error(`[Settings] FF API test KO (${url}):`, raw);
return { success: false, url, error: hint };
}
}
@@ -8,29 +8,33 @@ export interface DatabaseSettings {
port: string;
database: string;
user: string;
/** Saisie en cours uniquement : jamais enregistré dans le navigateur. */
password?: string;
ssl: boolean;
}
interface DatabaseSettingsState extends DatabaseSettings {
/** Un mot de passe est enregistré sur le serveur (lu au chargement de la page, jamais mémorisé). */
motDePasseEnregistre: boolean;
setHost: (host: string) => void;
setPort: (port: string) => void;
setDatabase: (database: string) => void;
setUser: (user: string) => void;
setPassword: (password: string) => void;
setSsl: (ssl: boolean) => void;
getDatabaseUrl: () => string;
setMotDePasseEnregistre: (motDePasseEnregistre: boolean) => void;
}
export const useDbSettingsStore = create<DatabaseSettingsState>()(
persist(
(set, get) => ({
(set) => ({
host: "localhost",
port: "5432",
database: "collectflow",
user: "postgres",
password: "",
ssl: false,
motDePasseEnregistre: false,
setHost: (host) => set({ host }),
setPort: (port) => set({ port }),
@@ -38,16 +42,19 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
setUser: (user) => set({ user }),
setPassword: (password) => set({ password }),
setSsl: (ssl) => set({ ssl }),
getDatabaseUrl: () => {
const { user, password, host, port, database, ssl } = get();
const auth = password ? `${user}:${password}` : user;
const url = `postgres://${auth}@${host}:${port}/${database}${ssl ? "?sslmode=require" : ""}`;
return url;
},
setMotDePasseEnregistre: (motDePasseEnregistre) => set({ motDePasseEnregistre }),
}),
{
name: "collectflow-db-settings",
// Le mot de passe reste sur le serveur : seuls les autres champs sont mémorisés.
partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }),
// Version 1 : les versions précédentes enregistraient le mot de passe en clair.
version: 1,
migrate: (persisted) => {
const reste = { ...(persisted as Partial<DatabaseSettings> | undefined) };
delete reste.password;
return reste as DatabaseSettingsState;
},
}
)
);
@@ -3,12 +3,14 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { and, eq, isNull } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { verifierSession } from "@/lib/authz";
export async function deleteSnapshot(id: number) {
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
@@ -29,8 +31,9 @@ export async function deleteSnapshot(id: number) {
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
return { success: true };
} catch (err: any) {
} catch (err) {
// Le détail technique reste dans les journaux du serveur.
console.error("[deleteSnapshot] DB Error:", err);
return { success: false, error: err.message || "Delete failed" };
return { success: false, error: "erreur technique sur le serveur" };
}
}
+6 -6
View File
@@ -2,14 +2,14 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { auth } from "@/lib/auth";
import { requireSession } from "@/lib/authz";
import { and, desc, eq, isNull } from "drizzle-orm";
export async function getSnapshots(type?: "snapshot" | "export") {
const utilisateur = await requireSession();
try {
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const rawUserId = utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
@@ -24,8 +24,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
.from(sessionSnapshots)
.where(and(...conditions))
.orderBy(desc(sessionSnapshots.createdAt));
} catch (err: any) {
const msg = (err?.message || String(err)).split("\n")[0];
} catch (err) {
const msg = ((err as Error)?.message || String(err)).split("\n")[0];
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
+10 -12
View File
@@ -4,7 +4,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
const SaveSnapshotSchema = z.object({
@@ -26,6 +26,9 @@ const SaveSnapshotSchema = z.object({
});
export async function saveSnapshot(raw: unknown) {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = SaveSnapshotSchema.safeParse(raw);
if (!parsed.success) {
console.error("Snapshot validation failed:", parsed.error.format());
@@ -33,9 +36,8 @@ export async function saveSnapshot(raw: unknown) {
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
@@ -59,7 +61,7 @@ export async function saveSnapshot(raw: unknown) {
revalidatePath("/snapshots");
revalidatePath("/exports");
return { success: true, snapshotId: created?.id };
} catch (err: any) {
} catch (err) {
console.error("Initial snapshot save failed, attempting auto-repair...", err);
try {
@@ -103,14 +105,10 @@ export async function saveSnapshot(raw: unknown) {
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: retryCreated?.id };
} catch (repairErr: any) {
} catch (repairErr) {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
console.error("Snapshot auto-repair/retry failed:", repairErr);
const origMsg = (err.message || String(err)).split('\n')[0];
const retryMsg = (repairErr.message || String(repairErr)).split('\n')[0];
return {
success: false,
error: `Erreur initiale: ${origMsg} | Erreur après réparation: ${retryMsg}`
};
return { success: false, error: "erreur technique sur le serveur" };
}
}
}
@@ -117,7 +117,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
setSnapshots((prev) => prev.filter((x) => x.id !== s.id));
toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique.");
} else {
toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`);
toast.erreur(`La suppression a échoué : ${(res.error ?? "erreur inconnue").replace(/\.$/, "")}.`);
}
} catch (err) {
if (isStaleServerActionError(err)) void proposerRechargement();