mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
Compare commits
3
Commits
b7ee413d67
...
acd51ecb88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd51ecb88 | ||
|
|
af5933b5a7 | ||
|
|
26b9364232 |
No files matched your search
@@ -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/
|
||||
Binary file not shown.
@@ -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.
|
||||
Generated
+104
-2002
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
},
|
||||
|
||||
Generated
-11081
File diff suppressed because it is too large.
Load diff
@@ -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 />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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é.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user