Compare commits

...
3 Commits
Author SHA1 Message Date
Claude acd51ecb88 Lot 7 : script de diagnostic de la base FF
scripts/diagnostic-ff.js (lecture seule) relève les réglages PostgreSQL, la
taille des tables, les types des colonnes de jointure, les index, les codes
article avec espaces et les plans d'exécution de six requêtes représentatives
(EXPLAIN, ou EXPLAIN ANALYZE avec --analyze). Il prépare les réécritures SQL
qui demandent la vraie base. Mode d'emploi dans docs/plan-optimisation-webui.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:49:07 +00:00
Claude af5933b5a7 Lot 6 : Grille plus légère, Commandes allégées et nettoyage
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:48:43 +00:00
Claude 26b9364232 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
2026-10-04 06:39:49 +00:00
63 changed files with 1737 additions and 13970 deletions

No files matched your search

+3
View File
@@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts
.playwright-mcp/
# données locales (configuration avec mots de passe, comptes de secours)
/data/
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+88
View File
@@ -376,3 +376,91 @@ Autres changements :
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
**Grille : changement de magasin**
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
- Il n'est demandé qu'une fois par magasin et par chargement.
- **Sélection** : remise à zéro seulement au changement de fournisseur.
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
**Sécurité**
- **Contrôle commun `lib/authz.ts`** :
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
- si la base est injoignable, le jeton de session fait foi.
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
- **Mots de passe et clés d'IA** :
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
- **Compte admin/admin** :
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
- `data/` est ignoré par git.
**À savoir après la mise en ligne**
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
## Suivi — Lot 6 réalisé (performances restantes et nettoyage)
**Grille**
- **Écriture des réglages dans le navigateur** : au plus une par demi-seconde, et immédiate quand la page est quittée. Avant, chaque changement d'état en déclenchait une. Mesure : 8 changements de gamme donnent 1 écriture au lieu de 16.
- **Calculs faits une seule fois par ligne** : la tendance réseau (au lieu d'un calcul par case et par rendu) et le texte de recherche (au lieu d'un calcul par ligne et par frappe). La petite courbe de tendance est mémorisée.
- **Barre du bas** : elle ne suit plus que le nombre de modifications ; le détail est lu au moment d'enregistrer.
**Commandes fournisseurs**
- **Montants de franco** : demandés à l'API FF 6 fournisseurs à la fois, au lieu de tous en même temps.
- **Dernière réception** : calculée pour les seuls fournisseurs du cadencier. Sur la base de test, les résultats sont identiques au calcul complet.
**Nettoyage**
- **Dépendances retirées** : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `remark-gfm`, `xlsx`, `@radix-ui/react-dropdown-menu`. Un `npm ci` complet a été vérifié.
- **`pnpm-lock.yaml` supprimé** : Docker et le README utilisent npm.
- **Code mort retiré** :
- `ui/textarea`, `debug/api/init-db`, `score/store` ;
- `getGridData` et `getAvailableNomenclature` ;
- 5 fonctions de l'ancien client HTTP FF, `pgGetArticlesByCodeCentrale` et `libelleGamme`.
- **Journaux à la racine supprimés** : `build.log`, `build_logs.txt`, `error.txt`, `tsc_errors.log`.
**Non fait volontairement**
- **Enrichissement de l'Accueil en SQL** :
- le cache de la page sert la version précédente pendant son recalcul, donc le gain serait faible ;
- et rien ne garantit que la base FF relie chaque mouvement à son fournisseur (colonne détectée au cas par cas) : le fournisseur affiché pourrait changer.
- À reprendre après le diagnostic du lot 7.
- **Largeurs de colonnes en variables CSS** : peu de gain maintenant que l'écriture des réglages est différée.
## Suivi — Lot 7 réalisé (diagnostic de la base FF)
Les réécritures SQL restantes demandent de connaître la vraie base FF : types des colonnes, index, volumes. Ce dépôt ne les contient pas. Elles concernent :
- `TRIM(a.codein::text)` dans une quinzaine de requêtes ;
- `TO_CHAR(datmvt)` dans les regroupements ;
- la jointure par code plutôt que par identifiant dans Meilleures ventes.
Le script `scripts/diagnostic-ff.js` les relève, en lecture seule :
- version et réglages de PostgreSQL ;
- taille des tables et date de dernière analyse ;
- types des colonnes de jointure et de filtre ;
- index existants ;
- nombre de codes article contenant des espaces (pour savoir si `TRIM()` est utile) ;
- plans d'exécution de six requêtes représentatives : recherche d'un article par code avec et sans `TRIM`, Meilleures ventes sur 7 jours, dernière réception, ventes mensuelles d'un fournisseur, stock sans vente.
**À lancer sur le serveur de production** (conteneur `web` du docker-compose) :
```bash
docker compose exec web node scripts/diagnostic-ff.js > diagnostic-ff.txt
# Avec les durées réelles (exécute les requêtes, à faire hors heures d'ouverture) :
docker compose exec web node scripts/diagnostic-ff.js --analyze > diagnostic-ff-analyze.txt
```
Le fichier produit ne contient aucun mot de passe, seulement des noms de tables, de colonnes et d'index, et des plans d'exécution. Une fois transmis, il permet de décider :
- quels `TRIM()` retirer ;
- quels index proposer à l'administrateur de la base FF ;
- s'il faut remplacer l'enrichissement HTTP de l'Accueil.
BIN
View File
Binary file not shown.
+104 -2002
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -12,12 +12,8 @@
"docker:build": "docker-compose up -d --build"
},
"dependencies": {
"@google/genai": "^1.49.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"ai": "^6.0.97",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
@@ -34,11 +30,8 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"ws": "^8.21.0",
"xlsx": "^0.18.5",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
-11081
View File
File diff suppressed because it is too large. Load diff
+232
View File
@@ -0,0 +1,232 @@
/**
* CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule).
*
* Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche
* produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas
* dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire
* quoi que ce soit.
*
* Usage (sur le serveur, ou dans le conteneur Docker de l'application) :
*
* node scripts/diagnostic-ff.js > diagnostic-ff.txt
* node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt
*
* Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus.
* `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée
* (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture.
* La session est en lecture seule et chaque requête est coupée au-delà de 2 min.
*
* Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL.
*/
const { Pool } = require("pg");
const fs = require("fs");
const path = require("path");
const ANALYZE = process.argv.includes("--analyze");
const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"];
const COLONNES = {
articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"],
mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"],
cube_stock: ["artnoid", "site", "qte"],
artfou1: ["no_id", "art_no_id", "code", "preference"],
fouident: ["code"],
};
function connexion() {
try {
const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json");
if (fs.existsSync(fichier)) {
const config = JSON.parse(fs.readFileSync(fichier, "utf-8"));
if (config.url) return config.url;
}
} catch {
/* configuration illisible : on tente la variable d'environnement */
}
return process.env.DATABASE_URL || "";
}
function titre(texte) {
console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`);
}
async function section(nom, fn) {
titre(nom);
const debut = Date.now();
try {
await fn();
} catch (e) {
console.log(` (indisponible : ${e.message})`);
}
console.log(` [${Date.now() - debut} ms]`);
}
async function plan(client, nom, requete, params = []) {
await section(`PLAN — ${nom}`, async () => {
const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT";
const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params);
for (const r of rows) console.log(` ${r["QUERY PLAN"]}`);
});
}
// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple).
const HIT_PARADE = `
WITH ventes AS (
SELECT TRIM(a.codein::text) AS codein, m.site,
SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge
FROM mvtart m JOIN articles a ON a.no_id = m.artnoid
WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL
GROUP BY TRIM(a.codein::text), m.site
),
attrs AS (
SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
FROM articles a
LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
stock_agg AS (
SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total
FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
GROUP BY TRIM(a2.codein::text)
)
SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total
FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein
ORDER BY v.ca_ttc DESC`;
const DERNIERE_RECEPTION = `
SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL
GROUP BY af.code, m.site`;
const MENSUEL_FOURNISSEUR = `
SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt)
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id
WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579')
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`;
const STOCK_SANS_VENTE = `
SELECT a.codein, m.site, MAX(m.datmvt)
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE)
AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3)
GROUP BY a.codein, m.site, cs.qte
HAVING COALESCE(cs.qte::float, 0) <> 0`;
async function main() {
const url = connexion();
if (!url) {
console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL.");
process.exit(1);
}
const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" });
const client = await pool.connect();
try {
await client.query("SET default_transaction_read_only = on");
await client.query("SET statement_timeout = '120s'");
await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application
console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`);
await section("Serveur PostgreSQL et réglages", async () => {
const { rows } = await client.query(`
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size',
'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target')
ORDER BY name`);
for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`);
});
await section("Taille des tables (lignes estimées, taille totale avec index)", async () => {
const { rows } = await client.query(`
SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille,
s.last_analyze, s.last_autoanalyze
FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1)
ORDER BY c.relname`, [TABLES]);
const trouvees = new Set(rows.map((r) => r.relname));
for (const r of rows) {
const analyse = r.last_analyze || r.last_autoanalyze;
console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`);
}
for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`);
});
await section("Types des colonnes utilisées dans les jointures et filtres", async () => {
for (const [table, colonnes] of Object.entries(COLONNES)) {
const { rows } = await client.query(`
SELECT column_name, data_type, character_maximum_length, table_schema
FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2)
ORDER BY column_name`, [table, colonnes]);
for (const r of rows) {
console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`);
}
}
});
await section("Index existants", async () => {
const { rows } = await client.query(`
SELECT tablename, indexname, indexdef FROM pg_indexes
WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]);
for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`);
if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)");
});
await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => {
const { rows } = await client.query(`
SELECT count(*) AS total,
count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces,
count(DISTINCT TRIM(codein::text)) AS distincts
FROM articles`);
const r = rows[0];
console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`);
try {
const { rows: c } = await client.query(`
SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces
FROM articles WHERE artcentrale IS NOT NULL`);
console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`);
} catch (e) {
console.log(` artcentrale : (indisponible : ${e.message})`);
}
});
// Paramètres d'exemple tirés de la base elle-même.
let codein = null;
let codefou = null;
try {
codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null;
codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null;
} catch {
/* tables absentes : les plans concernés le signaleront */
}
const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
await plan(client, `article par code, avec TRIM (codein ${codein})`,
"SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]);
await plan(client, `article par code, sans TRIM (codein ${codein})`,
"SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]);
await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]);
await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION);
await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]);
await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE);
} finally {
client.release();
await pool.end();
}
}
main().catch((e) => {
console.error("Diagnostic interrompu :", e.message);
process.exit(1);
});
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifierAdmin } from "@/lib/authz";
import { SynchronisationClient } from "./client";
export const metadata: Metadata = { title: "Synchronisation" };
@@ -6,9 +8,10 @@ export const metadata: Metadata = { title: "Synchronisation" };
/**
* Paramétrage de la synchronisation nocturne des fournisseurs.
*
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
* /api/admin/sync revérifient le rôle côté serveur.
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
*/
export default function SynchronisationPage() {
export default async function SynchronisationPage() {
if (!(await verifierAdmin()).ok) redirect("/dashboard");
return <SynchronisationClient />;
}
+60 -40
View File
@@ -30,7 +30,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st
import {
testDatabaseConnection,
saveDatabaseSettings,
getSavedDatabaseConfig,
getParametresAffichables,
saveQlikSettings,
testQlikConnection,
saveFfApiSettings,
@@ -45,12 +45,13 @@ import { ServerLogs } from "@/features/settings/components/server-logs";
/**
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
* serveur s'exécutent les unes après les autres.
* serveur s'exécutent les unes après les autres. Elle ne contient aucun mot de
* passe : seulement l'indication qu'il y en a un d'enregistré.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
savedConfigPromise = getParametresAffichables().catch((e) => {
savedConfigPromise = null;
throw e;
});
@@ -142,6 +143,13 @@ function ResultatTest({ test }: { test: EtatTest }) {
return null;
}
/** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */
function placeholderMotDePasse(enregistre: boolean): string {
return enregistre ? "•••••• (inchangé)" : "••••••••";
}
const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré.";
// ── PostgreSQL ───────────────────────────────────────────────────────────────
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
@@ -152,9 +160,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl,
motDePasseEnregistre, setMotDePasseEnregistre,
} = useDbSettingsStore();
// Mot de passe vide : le serveur reprend celui qui est enregistré.
const champs = () => ({ host, port, database, user, password: password || undefined, ssl });
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
const [enregistrement, setEnregistrement] = useState(false);
const [rechargement, setRechargement] = useState(false);
@@ -162,7 +173,7 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const tester = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testDatabaseConnection(getDatabaseUrl());
const res = await testDatabaseConnection(champs());
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
@@ -172,9 +183,14 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const enregistrer = async () => {
setEnregistrement(true);
try {
const res = await saveDatabaseSettings(getDatabaseUrl());
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
const res = await saveDatabaseSettings(champs());
if (res.success) {
toast.succes("Connexion à la base PostgreSQL enregistrée.");
if (password) {
setPassword("");
setMotDePasseEnregistre(true);
}
} else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
@@ -223,8 +239,8 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
</Champ>
<Champ id="pg-mdp" label="Mot de passe">
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Champ>
</div>
@@ -283,8 +299,8 @@ function SectionFfApi() {
useEffect(() => {
loadSavedConfig()
.then((cfg) => {
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
setSavedUrl(cfg.ffApiBaseUrl || null);
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
})
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
}, []);
@@ -418,7 +434,9 @@ function SectionFfApi() {
function SectionQlik() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
// Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur.
const [password, setPassword] = useState("");
const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false);
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
@@ -426,10 +444,9 @@ function SectionQlik() {
useEffect(() => {
loadSavedConfig()
.then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
setHost(c.qlik.host);
setUser(c.qlik.user);
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
})
.catch(() => { /* l'échec de lecture est signalé par la page */ });
}, []);
@@ -437,9 +454,14 @@ function SectionQlik() {
const handleSave = async () => {
setSaving(true);
try {
const res = await saveQlikSettings(host.trim(), user.trim(), password);
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
const res = await saveQlikSettings(host.trim(), user.trim(), password || undefined);
if (res.success) {
toast.succes("Identifiants Qlik enregistrés.");
if (password) {
setPassword("");
setMotDePasseEnregistre(true);
}
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
@@ -450,14 +472,15 @@ function SectionQlik() {
const handleTest = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testQlikConnection(host.trim(), user.trim(), password);
const res = await testQlikConnection(host.trim(), user.trim(), password || undefined);
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
}
};
const testImpossible = !user || !password;
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
const testImpossible = !user || (!password && !motDePasseEnregistre);
return (
<Card>
@@ -488,9 +511,9 @@ function SectionQlik() {
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
</Champ>
<Champ id="qlik-mdp" label="Mot de passe">
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
<div className="relative">
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
@@ -542,24 +565,21 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
const setUser = useDbSettingsStore((s) => s.setUser);
const setPassword = useDbSettingsStore((s) => s.setPassword);
const setSsl = useDbSettingsStore((s) => s.setSsl);
const setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre);
const reloadFromServer = useCallback(async (force = false) => {
const config = await loadSavedConfig(force);
if (config?.url) {
// Parser l'URL pour remettre dans le store
try {
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
setHost(url.hostname);
setPort(url.port || "5432");
setDatabase(url.pathname.slice(1).split("?")[0]);
setUser(url.username);
setPassword(decodeURIComponent(url.password));
setSsl(config.url.includes("sslmode=require"));
} catch (e) {
console.error("Failed to parse saved URL", e);
}
const { db } = await loadSavedConfig(force);
// Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré.
setPassword("");
setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false);
if (db) {
setHost(db.host);
setPort(db.port);
setDatabase(db.database);
setUser(db.user);
setSsl(db.ssl);
}
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
}, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
+5
View File
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifierAdmin } from "@/lib/authz";
import { ParametresClient } from "./client";
export const metadata: Metadata = { title: "Paramètres" };
@@ -11,6 +13,9 @@ export const metadata: Metadata = { title: "Paramètres" };
* lien se partage et survive au rechargement, puis rend la page cliente.
*/
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
// Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
if (!(await verifierAdmin()).ok) redirect("/dashboard");
const { onglet } = await props.searchParams;
return <ParametresClient onglet={onglet} />;
}
+5 -14
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { adminOuReponse } from "@/lib/authz";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { listGridSuppliers } from "@/lib/grid-store";
@@ -45,15 +45,6 @@ function publicJob(j: WarmupJob | null) {
return j;
}
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
try {
const fournisseurs = await pgGetFournisseurs();
@@ -102,8 +93,8 @@ async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
return NextResponse.json({ success: true, ...publicJob(job) });
}
@@ -112,8 +103,8 @@ export async function GET() {
* Démarre le préchauffage en arrière-plan et répond immédiatement.
*/
export async function POST(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
if (job?.status === "running") {
return NextResponse.json({ success: true, ...publicJob(job) });
+5 -14
View File
@@ -1,22 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { asc, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { adminOuReponse } from "@/lib/authz";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
* dernière synchronisation.
@@ -25,8 +16,8 @@ async function requireAdmin(): Promise<NextResponse | null> {
* besoin de la vue d'ensemble pour décider qui cadencer.
*/
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const rows = await db
.select()
@@ -65,8 +56,8 @@ const patchSchema = z.object({
* trompeur, alors même qu'il vient d'être rouvert volontairement.
*/
export async function PATCH(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
+7 -16
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { adminOuReponse } from "@/lib/authz";
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
import {
getSyncSchedulerState,
@@ -15,19 +15,10 @@ export const dynamic = "force-dynamic";
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
export const maxDuration = 300;
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const settings = await readSyncSettings();
const maintenant = new Date();
@@ -55,8 +46,8 @@ const settingsSchema = z.object({
/** PATCH — met à jour les réglages. */
export async function PATCH(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
@@ -87,8 +78,8 @@ const actionSchema = z.object({
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
export async function POST(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
+4
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { sessionOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
@@ -9,6 +10,9 @@ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
* Transfère les query params site et codefou si présents.
*/
export async function GET(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
try {
const { searchParams } = req.nextUrl;
const params = new URLSearchParams();
+9 -5
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
import { adminOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
@@ -31,6 +32,9 @@ async function probe(url: string) {
}
export async function GET(req: NextRequest) {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
const { searchParams } = new URL(req.url);
// Date range: 12 derniers mois
@@ -55,7 +59,7 @@ export async function GET(req: NextRequest) {
if (!firstCodein) {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
if (list.length > 0) firstCodein = (list[0] as Record<string, unknown>).codein as string ?? null;
@@ -80,13 +84,13 @@ export async function GET(req: NextRequest) {
referentiel_forced,
] = await Promise.all([
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
probe(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=2`),
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${codefou}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
// Test mensuel avec no_id auto-résolu
firstCodein
? (async () => {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const noid = list[0] ? (list[0] as Record<string, unknown>).no_id as string : null;
@@ -99,7 +103,7 @@ export async function GET(req: NextRequest) {
firstCodein
? (async () => {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=5`, { cache: "no-store" });
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const art = (list[4] ?? list[0]) as Record<string, unknown> | undefined;
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
import { adminOuReponse } from "@/lib/authz";
export const dynamic = "force-dynamic";
@@ -13,6 +14,9 @@ export const dynamic = "force-dynamic";
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
*/
export async function GET() {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
try {
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
} catch (e) {
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
import { sessionOuReponse } from "@/lib/authz";
const ExportSchema = z.object({
nomFournisseur: z.string(),
@@ -19,6 +20,9 @@ const ExportSchema = z.object({
});
export async function POST(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
import { sessionOuReponse } from "@/lib/authz";
const ExportModifiedGammesSchema = z.object({
nomFournisseur: z.string(),
@@ -13,6 +14,9 @@ const ExportModifiedGammesSchema = z.object({
});
export async function POST(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const body = await req.json();
const parsed = ExportModifiedGammesSchema.safeParse(body);
if (!parsed.success) {
+6 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { testFfApiConnection } from "@/features/settings/actions";
import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
import { sessionOuReponse } from "@/lib/authz";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -13,7 +14,10 @@ export const dynamic = "force-dynamic";
* détail, impossible à distinguer d'une simple erreur d'URL.
*/
export async function GET() {
const res = await testFfApiConnection();
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const res = await diagnostiquerApiFf();
if (!res.success) {
return NextResponse.json(
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
+4
View File
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { sessionOuReponse } from "@/lib/authz";
import type { GridFilters } from "@/types/grid";
export const dynamic = "force-dynamic";
@@ -11,6 +12,9 @@ function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, va
}
export async function GET(request: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const searchParams = request.nextUrl.searchParams;
const codeFournisseur = searchParams.get("fournisseur");
@@ -0,0 +1,34 @@
import { NextRequest } from "next/server";
import { getStorePatch } from "@/features/grid/api/get-product-rows";
import { MAGASINS } from "@/lib/magasins";
import { sessionOuReponse } from "@/lib/authz";
export const dynamic = "force-dynamic";
/**
* GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
*
* Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
* La Grille charge toujours les lignes « tous magasins » et applique ce
* complément à son arrivée : le changement de magasin n'attend plus rien.
*/
export async function GET(request: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
// Un code inconnu ferait de chaque article un candidat : autant d'appels
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
}
try {
const patch = await getStorePatch(codeFournisseur, magasin);
return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
return Response.json({ error: "Complément indisponible." }, { status: 502 });
}
}
+3 -12
View File
@@ -11,24 +11,15 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { adminOuReponse } from "@/lib/authz";
import { listCaptures, readCapture } from "@/lib/log-capture";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
export async function GET(req: NextRequest) {
const refus = await requireAdmin();
if (refus) return refus;
const refus = await adminOuReponse();
if (refus instanceof Response) return refus;
const id = req.nextUrl.searchParams.get("id");
if (!id) {
+3 -7
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionOuReponse } from "@/lib/authz";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
@@ -12,14 +12,10 @@ export const dynamic = "force-dynamic";
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
+7 -12
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionOuReponse } from "@/lib/authz";
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
@@ -21,23 +21,18 @@ export const dynamic = "force-dynamic";
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
* Contrôle de session explicite en plus du middleware : il répond ici en JSON,
* et relit le compte en base (cf. lib/authz).
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return null;
}
function lireTerme(req: NextRequest): string | null {
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
return q.length >= 3 ? q : null;
}
export async function POST(req: NextRequest) {
const denied = await requireSession();
if (denied) return denied;
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
@@ -54,8 +49,8 @@ export async function POST(req: NextRequest) {
}
export async function GET(req: NextRequest) {
const denied = await requireSession();
if (denied) return denied;
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
+9 -37
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
@@ -121,34 +121,6 @@ function idleJob(fournisseur: string, codeCentrale?: string) {
};
}
/**
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
*/
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* Vérifie qu'une session existe (sans exiger le rôle admin).
*
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
* `/api/*`, le contrôle doit donc être fait ici explicitement.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
@@ -337,8 +309,8 @@ export async function GET(req: NextRequest) {
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await requireSession();
if (denied) return denied;
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
@@ -347,8 +319,8 @@ export async function GET(req: NextRequest) {
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
@@ -371,8 +343,8 @@ export async function POST(req: NextRequest) {
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await requireSession();
if (denied) return denied;
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
@@ -414,8 +386,8 @@ export async function POST(req: NextRequest) {
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await requireAdmin();
if (denied) return denied;
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
+8 -2
View File
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
import { db } from '@/db';
import { sql } from 'drizzle-orm';
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
import { adminOuReponse } from '@/lib/authz';
export async function GET(request: Request) {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
try {
const { searchParams } = new URL(request.url);
const codein = searchParams.get('codein') || '334152';
@@ -24,7 +28,9 @@ export async function GET(request: Request) {
stock: Object.fromEntries(map),
fou: fouResult.rows,
});
} catch (e: any) {
return NextResponse.json({ error: e.message, stack: e.stack }, { status: 500 });
} catch (e) {
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
console.error('[api/test-db]', e);
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+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;
}
+18 -6
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,13 +47,14 @@ 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 {
[rows, receptions] = await Promise.all([
db.select().from(commandeCadences),
getDerniereReceptionCached(),
]);
rows = await db.select().from(commandeCadences);
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
// l'historique de tous les fournisseurs était la plus lourde de la page.
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
} catch (e) {
console.error("[cadence] listCadences error:", (e as Error).message);
return [];
@@ -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)
-25
View File
@@ -1,25 +0,0 @@
"use server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
export async function initSnapshotTable() {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
return { success: true };
} catch (err) {
console.error("Init DB error:", err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
+3 -21
View File
@@ -2,13 +2,13 @@
import { getSitesFromApi } from "@/lib/api-ff-client";
import { getFournisseursCached } from "@/lib/ff-cache";
import { getProductRows } from "./api/get-product-rows";
import type { ProductRow, GridFilters } from "@/types/grid";
import { requireSession } from "@/lib/authz";
/**
* 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,24 +18,6 @@ export async function getFournisseurs() {
* Get the list of all stores (sites) from the FF Nancy API.
*/
export async function getMagasins() {
await requireSession();
return getSitesFromApi();
}
/**
* Nomenclature filter is disabled — code3 is not available from the API.
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
return {};
}
/**
* Get product data for a specific supplier and store.
*/
export async function getGridData(
codeFournisseur: string,
magasin: string = "TOTAL",
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
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 {
@@ -62,8 +62,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
+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()}`);
};
+60 -20
View File
@@ -11,6 +11,7 @@ import {
ColumnDef,
SortingState,
RowSelectionState,
type FilterFn,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
@@ -46,12 +47,15 @@ import {
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
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. */
@@ -60,6 +64,39 @@ interface HeatmapGridProps {
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
/*
* Valeurs dérivées calculées une fois par ligne. Une ligne reçue ne change plus
* (une modification produit un nouvel objet) : on peut les garder tant qu'elle
* existe, sans rien invalider. Avant, la tendance était recalculée à chaque
* rendu de chaque case, et le texte de recherche à chaque frappe.
*/
const tendances = new WeakMap<ProductRow, NetworkTrend>();
function tendanceDe(row: ProductRow): NetworkTrend {
let t = tendances.get(row);
if (!t) {
t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
tendances.set(row, t);
}
return t;
}
const textesRecherche = new WeakMap<ProductRow, string>();
function texteRecherche(row: ProductRow): string {
let t = textesRecherche.get(row);
if (t === undefined) {
// Saut de ligne entre les champs : une recherche ne peut pas en contenir,
// donc aucune correspondance « à cheval » sur deux champs.
t = [row.libelle1, row.codein, row.reference, row.gtin].map((v) => String(v ?? "")).join("\n").toLowerCase();
textesRecherche.set(row, t);
}
return t;
}
/** Recherche sur libellé, code, référence et code-barres. */
const filtreRecherche: FilterFn<ProductRow> = (row, _columnId, valeur: string) => texteRecherche(row.original).includes(valeur);
// Saisie mise en minuscules une seule fois par frappe, pas une fois par ligne.
filtreRecherche.resolveFilterValue = (valeur) => String(valeur ?? "").toLowerCase();
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"caReseau",
"qteReseau",
@@ -526,7 +563,7 @@ interface CellDetailData {
* d'identité — sans quoi la même couleur voudrait dire deux choses.
*/
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const trend = tendanceDe(row);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
@@ -699,10 +736,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 +754,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 +776,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 +820,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>[]>(() => [
{
@@ -956,7 +1003,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
{
id: "tendanceReseau",
accessorFn: (row) => {
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const t = tendanceDe(row);
if (!t.hasData) return Number.NEGATIVE_INFINITY;
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
// forte progression possible : il doit remonter en tête du tri, pas
@@ -967,7 +1014,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 78,
cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
const trend = tendanceDe(row.original);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
@@ -1198,7 +1245,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,
@@ -1215,14 +1262,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
// colonne filtrable (une quinzaine) à chaque frappe.
getColumnCanGlobalFilter: (column) => column.id === "codein",
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase();
const libelle = String(row.original.libelle1 || "").toLowerCase();
const code = String(row.original.codein || "").toLowerCase();
const reference = String(row.original.reference || "").toLowerCase();
const gtin = String(row.original.gtin || "").toLowerCase();
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
},
globalFilterFn: filtreRecherche,
});
const { rows: tableRows } = table.getRowModel();
@@ -1249,7 +1289,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({
@@ -7,13 +7,13 @@
* produit (`/produits`). Aucun changement de comportement.
*/
import { useState } from "react";
import { memo, useState } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
function TrendSparklineInner({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct, nouveau } = trend;
const color = TREND_COLOR[direction];
@@ -51,6 +51,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
);
}
/** Mémorisée : la tendance d'une ligne est calculée une fois et ne change plus. */
export const TrendSparkline = memo(TrendSparklineInner);
/**
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
+26 -33
View File
@@ -1,36 +1,32 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/** Modifications portant sur les lignes chargées (le fournisseur affiché). */
function modificationsActives(draftChanges: Record<string, GammeCode>, rowsByCodein: Record<string, unknown>) {
return Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => rowsByCodein[codein]));
}
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Filters changes to only include those in the provided codeins list (e.g. current supplier).
* Enregistre les gammes modifiées du fournisseur affiché (server action).
*
* Le composant ne s'abonne qu'au NOMBRE de modifications : l'abonnement aux
* modifications elles-mêmes le redessinait à chaque changement de gamme. Le
* détail est lu au moment d'enregistrer.
*/
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const draftChanges = useGridStore((s) => s.draftChanges);
const rowsByCodein = useGridStore((s) => s.rowsByCodein);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const clearDrafts = useGridStore((s) => s.clearDrafts);
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
// Only consider changes that are in the filter list (if provided)
const filterCodeinSet = useMemo(
() => filterCodeins ? new Set(filterCodeins) : null,
[filterCodeins]
);
const activeDrafts = useMemo(
() => filterCodeinSet
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein)))
: draftChanges,
[draftChanges, filterCodeinSet]
);
const count = Object.keys(activeDrafts).length;
export function useSaveDrafts(magasin: string) {
const count = useGridStore((s) => {
let n = 0;
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return n;
});
const save = useCallback(async () => {
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
@@ -44,25 +40,22 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
if (changes.length === 0) return { success: true, saved: 0 };
// Derive supplier info from the first matching row
const firstRow = changes.length > 0 ? rowsByCodein[changes[0].codein] : undefined;
// Fournisseur déduit de la première ligne modifiée
const firstRow = rowsByCodein[changes[0].codein];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
if (result.success) {
// Apply saved values onto row.codeGamme BEFORE clearing drafts
// so that isModified (codeGamme !== codeGammeInit) stays true
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
// modifications, pour que l'indicateur « modifié » (codeGamme !==
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
if (filterCodeins) {
clearDrafts(Object.keys(activeDrafts));
} else {
resetDrafts();
}
clearDrafts(Object.keys(activeDrafts));
}
return result;
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
}, [magasin]);
return { save, hasDrafts: count > 0, count };
}
@@ -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],
};
}
+55 -5
View File
@@ -2,7 +2,21 @@
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[];
}
/** 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 +39,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 +250,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) });
},
@@ -271,6 +318,9 @@ export const useGridStore = create<GridState>()(
}),
{
name: "collectflow-grid-storage",
// Écriture différée (500 ms) : `persist` réécrivait le localStorage à
// chaque changement d'état, y compris pendant un redimensionnement.
storage: createThrottledJSONStorage<GridPersisted>(500),
/**
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
@@ -1,27 +0,0 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
/**
* Score Settings Store — v4 (simplifié)
*
* Le score hybride v4 utilise des paliers absolus (Volume 50pts + CA 30pts + Marge 20pts + Bonus ±10pts).
* Il n'y a plus de pondérations configurables par l'utilisateur.
* Ce store est conservé pour la compatibilité mais n'a plus de paramètres ajustables.
*/
interface ScoreSettingsState {
_version: number;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
() => ({
_version: 4,
}),
{
name: "collectflow-score-settings",
}
)
);
+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();
-115
View File
@@ -291,52 +291,6 @@ export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string }
};
}
/** "2025-03-15" → "202503" */
export function dateToYYYYMM(isoDate: string): string {
return isoDate.slice(0, 7).replace("-", "");
}
// ---------------------------------------------------------------------------
// Fournisseurs
// ---------------------------------------------------------------------------
/** Extrait le code fournisseur depuis n'importe quelle forme de réponse API */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouCode(f: any): string {
return f.codefou ?? f.CodeFou ?? f.code_fournisseur ?? f.codeFournisseur ?? f.code ?? String(f.id ?? "");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouNom(f: any): string {
return f.nomfou ?? f.NomFou ?? f.nom_fournisseur ?? f.nomFournisseur ?? f.nom ?? f.libelle ?? f.name ?? "Inconnu";
}
export async function getFournisseursFromApi(
search?: string
): Promise<{ code: string; nom: string }[]> {
const FF_API_BASE = await getFfApiBase();
try {
const url = search
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
console.log(`[api-ff] GET ${url}`);
const res = await fetch(url, ffFetchInit());
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
const data = await res.json();
// Log premier item pour diagnostic
const rawList = extractList(data);
console.log(`[api-ff] fournisseurs: ${rawList.length} items, sample keys:`, rawList[0] ? Object.keys(rawList[0] as object) : "empty");
return rawList
.map((f: unknown) => ({ code: extractFouCode(f), nom: extractFouNom(f) }))
.filter((f: { code: string; nom: string }) => f.code);
} catch (err) {
console.error("[api-ff] getFournisseursFromApi error:", err);
return [];
}
}
// ---------------------------------------------------------------------------
// Articles
// ---------------------------------------------------------------------------
@@ -370,63 +324,6 @@ export async function getArticlesByFournisseur(
// Mouvements (ventes + stock12m)
// ---------------------------------------------------------------------------
export async function getMouvementsByFournisseur(
codefou: string,
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
/**
* Retourne les mouvements pour une plage de dates (sans filtre fournisseur).
* Utilisé pour extraire codefou_reel / nom_fournisseur_reel par codein.
*/
export async function getMouvementsForDate(
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
// ---------------------------------------------------------------------------
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
@@ -608,18 +505,6 @@ export async function getCommandesByFournisseur(
// Statut de synchronisation
// ---------------------------------------------------------------------------
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
try {
const FF_API_BASE = await getFfApiBase();
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
if (!res.ok) return null;
return normalizeSyncStatus(await res.json());
} catch (err) {
console.error("[api-ff] getSyncStatus error:", err);
return null;
}
}
// ---------------------------------------------------------------------------
// Sites (magasins)
// ---------------------------------------------------------------------------
+4 -6
View File
@@ -24,7 +24,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
try {
console.log(`[AUTH] Checking user "${credentials.username}"...`);
let user: any = null;
let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined;
// Try DB first
try {
@@ -33,12 +33,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
.where(eq(users.username, credentials.username as string));
user = dbUser;
console.log(`[AUTH] DB check: User found: ${!!user}`);
} catch (dbErr) {
} catch {
// Base injoignable uniquement : un compte absent de la base ne doit
// pas pouvoir se connecter avec le fichier de secours (admin/admin).
console.warn("[AUTH] DB unreachable, falling back to JSON.");
}
// Fallback to JSON if DB failed or user not found
if (!user) {
const fallbackUsers = getFallbackUsers();
user = fallbackUsers.find(u => u.username === credentials.username);
console.log(`[AUTH] JSON check: User found: ${!!user}`);
+164
View File
@@ -0,0 +1,164 @@
/**
* CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages.
*
* Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son
* expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
* relit donc l'utilisateur dans la table `users` (avec un cache court), et le
* rôle enregistré en base l'emporte sur celui du jeton.
*
* Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte
* de secours `data/users.json` ne pourrait plus rien faire — y compris configurer
* la base.
*/
import "server-only";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
export interface UtilisateurCourant {
id: string;
username: string;
role: string;
}
const MESSAGE_SESSION = "Session expirée : reconnectez-vous.";
const MESSAGE_ADMIN = "Action réservée aux administrateurs.";
/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */
export class AccesRefuse extends Error {
constructor(message: string, readonly statut: 401 | 403 = 403) {
super(message);
this.name = "AccesRefuse";
}
}
// ── Relecture du compte en base ──────────────────────────────────────────────
/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */
const CACHE_TTL_MS = 60_000;
/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */
const PAUSE_BASE_INJOIGNABLE_MS = 15_000;
interface FicheUtilisateur {
exists: boolean;
role: string;
username: string;
at: number;
}
const cache = new Map<string, FicheUtilisateur>();
let baseInjoignableDepuis = 0;
/** Plus grand entier accepté par la colonne `users.id` (serial). */
const ID_MAX = 2_147_483_647;
/**
* Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre.
* Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non
* numérique) est cherché tel quel : introuvable si la base répond.
*/
async function lireFiche(id: string): Promise<FicheUtilisateur | null> {
const maintenant = Date.now();
const enCache = cache.get(id);
if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache;
if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null;
const n = /^\d+$/.test(id) ? Number(id) : -1;
const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1;
try {
const [ligne] = await db
.select({ username: users.username, role: users.role })
.from(users)
.where(eq(users.id, idNumerique))
.limit(1);
const fiche: FicheUtilisateur = ligne
? { exists: true, role: ligne.role, username: ligne.username, at: maintenant }
: { exists: false, role: "", username: "", at: maintenant };
cache.set(id, fiche);
return fiche;
} catch (e) {
baseInjoignableDepuis = maintenant;
console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200));
return null;
}
}
// ── Contrôles ────────────────────────────────────────────────────────────────
/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */
export async function requireSession(): Promise<UtilisateurCourant> {
const session = await auth();
const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined;
if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401);
const id = jeton.id != null ? String(jeton.id) : "";
const fiche = await lireFiche(id);
if (!fiche) {
// Mode dégradé : le jeton fait foi.
return { id, username: jeton.name ?? "", role: jeton.role ?? "user" };
}
if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401);
return { id, username: fiche.username, role: fiche.role };
}
/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */
export async function requireAdmin(): Promise<UtilisateurCourant> {
const utilisateur = await requireSession();
if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403);
return utilisateur;
}
// ── Variantes sans exception ─────────────────────────────────────────────────
export type Autorisation =
| { ok: true; utilisateur: UtilisateurCourant }
| { ok: false; message: string; statut: 401 | 403 };
async function verifier(controle: () => Promise<UtilisateurCourant>): Promise<Autorisation> {
try {
return { ok: true, utilisateur: await controle() };
} catch (e) {
if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut };
throw e;
}
}
/**
* Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt
* que de lever une exception, et pour les pages (le `redirect()` doit être appelé
* hors d'un try/catch).
*/
export function verifierSession(): Promise<Autorisation> {
return verifier(requireSession);
}
export function verifierAdmin(): Promise<Autorisation> {
return verifier(requireAdmin);
}
// ── Variantes pour les routes ────────────────────────────────────────────────
function reponse(a: Autorisation): UtilisateurCourant | Response {
return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut });
}
/**
* Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle :
*
* const acces = await sessionOuReponse();
* if (acces instanceof Response) return acces;
*/
export async function sessionOuReponse(): Promise<UtilisateurCourant | Response> {
return reponse(await verifierSession());
}
/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */
export async function adminOuReponse(): Promise<UtilisateurCourant | Response> {
return reponse(await verifierAdmin());
}
+8 -5
View File
@@ -100,12 +100,15 @@ export function getFournisseursCached(): Promise<{ code: string; nom: string }[]
}
/**
* Dernière réception par fournisseur et magasin (cadencier). Agrège tout
* l'historique de `mvtart` : de loin la lecture la plus lourde de la page
* Commandes. Vide = erreur avalée, non gardé.
* Dernière réception par fournisseur et magasin, pour les fournisseurs du
* cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
* Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
* Vide = erreur avalée (ou aucune réception), non gardé.
*/
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
export function getDerniereReceptionCached(codes: string[]): Promise<Map<string, string>> {
const liste = [...new Set(codes.map((c) => c.trim()))].sort();
if (liste.length === 0) return Promise.resolve(new Map());
return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), {
cacheIf: (map) => map.size > 0,
});
}
-6
View File
@@ -58,9 +58,3 @@ export function trouverGamme(code: string | null | undefined): Gamme | undefined
if (!code) return undefined;
return GAMMES.find((g) => g.code === code.trim());
}
/** « A — Cœur », ou « Sans gamme ». */
export function libelleGamme(code: string | null | undefined): string {
const g = trouverGamme(code);
return g ? `${g.code} — ${g.nom}` : "Sans gamme";
}
+52 -73
View File
@@ -25,6 +25,28 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
// trois allers-retours de plus à chacune.
// ---------------------------------------------------------------------------
/**
* `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre
* (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à
* l'API FF d'un coup, qui la saturent et finissent en délais dépassés.
*/
async function parLots<T, R>(items: T[], simultanes: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const resultats: PromiseSettledResult<R>[] = new Array(items.length);
let suivant = 0;
const ouvrier = async () => {
while (suivant < items.length) {
const i = suivant++;
try {
resultats[i] = { status: "fulfilled", value: await fn(items[i]) };
} catch (reason) {
resultats[i] = { status: "rejected", reason };
}
}
};
await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier));
return resultats;
}
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
const r = await db.execute(query);
return { rows: r.rows };
@@ -156,55 +178,6 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
}
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
export interface PgArticleLocal {
codein: string;
codeCentrale: string;
libelle1?: string;
codefou?: string;
nomfou?: string;
pa?: number;
pv_central?: number;
}
/**
* Résout des codes centraux vers NOS articles locaux.
*
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
* absent de la Map = produit inconnu de FF Nancy.
*/
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.artcentrale)
TRIM(a.codein::text) AS codein,
a.artcentrale AS "codeCentrale",
a.libelle1,
af.code AS codefou,
fi.nom AS nomfou,
pa.pa,
ai.prix_vente_mini AS pv_central
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY a.artcentrale, af.no_id
`);
const map = new Map<string, PgArticleLocal>();
for (const r of result.rows as unknown as PgArticleLocal[]) {
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
}
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
return map;
}
// ---------------------------------------------------------------------------
// 2. Données mensuelles (ventes + stock + réceptions)
// ---------------------------------------------------------------------------
@@ -1426,29 +1399,28 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0);
};
const details = await Promise.allSettled(
uniqueCodeFous.map(async codefou => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
})
);
// 6 fournisseurs à la fois : chacun peut coûter deux appels successifs.
const details = await parLots(uniqueCodeFous, 6, async (codefou) => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
});
// Map codefou → franco enrichi
const francoMap = new Map<string, number>();
@@ -1490,8 +1462,14 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
* (l'UI affichera alors « échéance inconnue »).
*/
export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string, string>> {
export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
// Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles,
// au lieu de tout l'historique de réceptions de tous les fournisseurs.
if (codes && codes.length === 0) return map;
const filtreFournisseurs = codes
? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})`
: sql``;
try {
const result = await pgNoParallel(sql`
SELECT
@@ -1504,6 +1482,7 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND m.datmvt IS NOT NULL
${filtreFournisseurs}
GROUP BY af.code, m.site
`);
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware";
/**
* Stockage `persist` de zustand qui n'écrit dans le localStorage qu'au plus une
* fois par `delaiMs`.
*
* `persist` réécrit (et resérialise) l'état à chaque `set`, quel que soit le
* champ modifié : deux fois par changement de gamme, et à chaque mouvement de
* souris pendant qu'on élargit une colonne. Ici, seule la dernière valeur est
* écrite, et elle l'est aussitôt quand la page est quittée ou masquée.
*/
export function createThrottledJSONStorage<S>(delaiMs = 500): PersistStorage<S> | undefined {
// Rendu serveur : même repli que zustand par défaut (rien n'est persisté).
if (typeof window === "undefined") return undefined;
const inner = createJSONStorage<S>(() => window.localStorage);
if (!inner) return undefined;
let enAttente: { name: string; value: StorageValue<S> } | null = null;
let minuteur: number | null = null;
const ecrire = () => {
if (minuteur !== null) {
clearTimeout(minuteur);
minuteur = null;
}
if (!enAttente) return;
const { name, value } = enAttente;
enAttente = null;
try {
inner.setItem(name, value);
} catch {
/* quota dépassé ou stockage bloqué : ignoré, comme avant */
}
};
window.addEventListener("pagehide", ecrire);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") ecrire();
});
return {
getItem: (name) => (enAttente?.name === name ? enAttente.value : inner.getItem(name)),
setItem: (name, value) => {
enAttente = { name, value };
if (minuteur === null) minuteur = window.setTimeout(ecrire, delaiMs);
},
removeItem: (name) => {
if (enAttente?.name === name) enAttente = null;
inner.removeItem(name);
},
};
}
-7
View File
@@ -1,7 +0,0 @@
next.config.ts(9,3): error TS2353: Object literal may only specify known properties, and 'eslint' does not exist in type 'NextConfig'.
src/features/grid/components/supplier-ai-context-modal.tsx(4,24): error TS2307: Cannot find module '@/components/ui/button' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(5,114): error TS2307: Cannot find module '@/components/ui/dialog' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(6,26): error TS2307: Cannot find module '@/components/ui/textarea' or its corresponding type declarations.
src/features/grid/components/supplier-ai-context-modal.tsx(87,36): error TS7006: Parameter 'e' implicitly has an 'any' type.
src/lib/mock-data.ts(66,19): error TS2345: Argument of type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is not assignable to parameter of type 'ProductRow'.
Type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is missing the following properties from type 'ProductRow': code1, libelleNiveau1, code2, libelleNiveau2, and 2 more.