Compare commits

...
Author SHA1 Message Date
Claude 2c84cfda53 perf(stock): onglets instantanés, pagination et fournisseur de dernière entrée
Le changement d'onglet déclenchait un router.push : la page serveur était
rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données
étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué.

Interface (stock-negatif/client.tsx)
- Changement d'onglet purement client (useTransition + history.replaceState) :
  plus aucune requête SQL rejouée, l'URL reste partageable.
- Modal de chargement pendant le changement de magasin (seule action qui
  interroge vraiment le serveur), pendant un changement d'onglet lent et
  pendant la génération de l'export Excel. Affichage différé de 120 ms pour
  éviter tout clignotement.
- Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000
  lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de
  reprendre l'intégralité des lignes filtrées.
- Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri
  alphabétique sur codein écrasait le classement métier), collateur Intl
  réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste
  aux valeurs nulles.
- Les 3 onglets partagent désormais un composant de tableau unique piloté par
  une configuration de colonnes, au lieu de 3 copies quasi identiques.
- Select magasin et onglets désactivés pendant le chargement, aria-busy posé.

Données (pg-ff-client.ts)
- Un article n'est plus rattaché à son fournisseur principal mais au
  fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre
  fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui
  porte ce lien est détectée une fois par process parmi une liste blanche
  (même principe que getNomenclatureParentCol), avec repli documenté sur
  artfou1.preference = 1 si la base ne porte pas l'information.
- La jointure latérale sur la dernière entrée remplace la sous-requête
  corrélée MAX(datmvt) : même coût qu'avant pour une information de plus.
- pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux
  requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les
  filtrer côté client : le compteur d'onglet correspond enfin aux lignes
  réellement transmises.

Diagnostic
- GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de
  mvtart et échantillon comparant fournisseur principal et fournisseur de
  dernière entrée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X
2026-09-22 10:29:00 +00:00
Claude 585e171708 fix(grid): aligner la fenêtre 12 mois sur les données et tabuler les ventes du modal
Le total « Tot. 12m » est figé côté serveur sur SA fenêtre de 12 mois, tandis
que la Grille et le modal recalculaient les 12 mois depuis l'horloge du
navigateur. Dès que les deux divergeaient (onglet ouvert au changement de
mois, lignes servies depuis le cache), un mois de ventes disparaissait des
colonnes tout en restant compté dans le total : 28 sur la ligne, 20 dans le
modal.

- months.ts : `getMonthsFromRows()` lit la fenêtre dans les clés des séries
  reçues ; `getLast12Months()` ne sert plus que de repli avant chargement.
- heatmap-grid : MONTHS_12 dérivé des lignes chargées.
- product-monthly-modal : le tableau par magasin devient générique et sert
  aussi aux ventes, sous le graphique, avec une colonne de cumul « 12 m »
  qui retombe sur le total de la Grille.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177HLQz6w2hgoXpx6t1Rkn9
2026-09-02 10:43:26 +00:00
Claude 2404b22628 feat(grille): fige les colonnes d'identité à gauche
Sur 2600 px de tableau pour ~1150 px visibles, on voit 8 colonnes sur 18. Dès
qu'on défilait vers les totaux ou les mois, la Désignation sortait de l'écran
et plus rien ne disait quelle ligne on lisait.

Case à cocher, Code interne et Désignation restent désormais collés au bord
gauche pendant le défilement horizontal, en-tête compris.

Deux conséquences de conception :

- La Désignation a été rapprochée du Code interne dans l'ordre des colonnes.
  Le figeage exige des colonnes contiguës depuis la gauche ; en la laissant
  après Référence et GTIN, il aurait fallu geler ces deux-là aussi, soit 636 px
  immobilisés au lieu de 406. L'ordre « code, désignation, puis codes
  secondaires » est de toute façon celui qu'on lit.

- Les cellules figées doivent être OPAQUES, le reste de la ligne glissant
  dessous. Le survol passe donc par une teinte pleine et non par le voile
  translucide des cellules ordinaires, et l'en-tête figé reprend le dégradé du
  thead pour que la bande reste d'un seul tenant. Un filet marque la limite de
  la zone figée.

Les décalages sont calculés par cumul des largeurs réelles et s'arrêtent à la
première colonne non figée : masquer le Code interne réajuste tout seul, et une
colonne figée ne peut jamais se retrouver à flotter au milieu du tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 09:45:17 +00:00
Claude 7f5ef93469 fix(grille): défilement horizontal visible et barre du bas dans le flux
Les deux symptômes n'en faisaient qu'un. L'ascenseur horizontal EXISTAIT déjà
(conteneur en overflow-auto, table en min-width ~2600 px), mais il était caché
sous la carte de synthèse.

Celle-ci était en `position: fixed` et ne réservait donc aucune hauteur : seul
un `pb-12` posé sur la page compensait, trop court d'une quarantaine de pixels
— exactement la bande où se trouvent la barre de défilement et la dernière
ligne. Son `left-[264px]` était en prime calé sur une barre latérale dépliée,
alors qu'elle est repliée par défaut : 200 px de vide à gauche, qui écrasaient
ses statistiques en hauteur. Elle est remise dans le flux : la grille lui cède
exactement la place nécessaire, et il n'y a plus de valeur magique à tenir en
phase avec la sidebar.

L'ascenseur devient visible : la règle générale (8 px, rail transparent, pouce
à 12 % d'opacité) convient aux panneaux où le défilement est accessoire, pas à
la Grille où il est l'outil de navigation principal. Classe `.grid-scroll`
dédiée, avec rail dessiné, pouce saisissable, et prise en charge de Firefox que
les sélecteurs -webkit- ignorent.

Corrigé aussi :
- totalWidth lisait `columnDef.size` au lieu de `getSize()`. Le redimensionnement
  de colonnes étant actif et persisté, la largeur plancher restait figée sur
  l'ancienne somme : dernières colonnes rognées, course de défilement trop courte.
- Conteneur non focusable : flèches, Origine et Fin ne défilaient pas la grille.
- Barre de filtres qui passait sur deux lignes sous ~1200 px, volant 48 px au
  tableau : largeurs réduites en dessous de xl.
- Barre d'actions groupées sans flex-wrap, qui s'écrasait sur petit écran.
- Double croix dans la recherche : `type="search"` ajoutait la croix native du
  navigateur par-dessus le bouton maison. Masquage local — la recherche de
  l'en-tête n'a pas de bouton propre et garde donc le sien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 08:52:28 +00:00
LogiFlow 60ce3b8d21 Merge pull request #13 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 10:45:43 +02:00
Claude 828a50bfc0 Trous d'assortiment : la référence à côté du code interne
Le code interne identifie l'article chez nous, la référence l'identifie chez
le fournisseur : c'est elle qu'on recopie sur une commande. La fenêtre ne
donnait que le premier, obligeant à revenir à la Grille pour retrouver le
second — l'export, lui, portait déjà les deux.

Référence absente affichée « — » plutôt que laissée vide : une cellule vide
se lit comme une colonne qui n'a pas fini de charger.

Vérifié sur build de production, avec des références longues et absentes :
colonne lisible, aucun débordement horizontal de la fenêtre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:42:29 +00:00
Claude f75d51f6f4 Trous d'assortiment : extraction Excel de la liste affichée
Un bouton « Export Excel » dans la fenêtre des produits non travaillés. Il
reprend EXACTEMENT ce qui est à l'écran — même magasin, même profondeur,
même classement — plutôt que de recalculer une liste : un fichier qui ne
correspond pas à la fenêtre d'où il sort ne se vérifie plus.

Deux feuilles. Les données seules d'abord : en-têtes en ligne 1, filtre
automatique, volet figé, et surtout des valeurs NUMÉRIQUES — le tableur doit
pouvoir trier et sommer, ce qu'une chaîne « 89 863 € » lui interdit. La mise
en forme des nombres suit le critère de tri (euros, unités, pourcentage). Le
contexte ensuite, sur sa propre feuille : fournisseur, magasin examiné,
classement retenu, définition du « non travaillé », date. Mêler les deux
dans une feuille unique aurait condamné filtre et tri.

`exceljs` est chargé à la demande plutôt qu'importé en tête : il pèse lourd,
et la Grille n'a pas à le transporter pour tous ceux qui n'exportent jamais.
Un échec de génération s'affiche dans la fenêtre — un export qui échoue en
silence laisse croire au téléchargement.

Le nom du fournisseur descend jusqu'à la fenêtre pour nommer et documenter
le fichier.

Vérifié sur build de production, en téléchargeant le fichier puis en le
relisant : « Non_travailles_Fournisseur_Test_Houdemont_Top200_2026-08-14 »,
deux feuilles, 15 lignes conformes au décompte affiché, filtre automatique
sur A1:J1, valeurs lues comme des nombres et format « #,##0 "€" » sur la
colonne du critère.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:36:20 +00:00
Claude beefb07ba0 Grille : les produits qu'un magasin ne travaille pas
Un bouton « Non travaillés » ouvre, sur le haut du classement AFFICHÉ, la
liste des produits qu'un magasin ne travaille pas. Le classement de
référence est celui du tableau à l'instant où l'on ouvre : le tri en cours —
CA réseau, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a
servi à classer est relue sur la colonne triée pour être affichée en regard.
Recalculer un ordre maison aurait répondu à une autre question que celle
qu'on vient de poser à l'écran.

« Non travaillé » = aucune vente sur 12 mois glissants ET aucun stock au
dernier mois connu. Les deux conditions comptent : un produit sans vente
mais en stock est détenu par le magasin — c'est un invendu, pas un trou
d'assortiment, et le mélanger aux vrais trous ferait commander ce qui dort
déjà en rayon.

Chaque ligne porte son rang, la valeur du critère de tri, et ce que les
AUTRES magasins en font : sans les ventes d'à côté, un trou ne se distingue
pas d'un produit que personne ne vend. Une dernière colonne sépare « jamais
détenu » de « déjà détenu, sans vente » — le premier est une piste, le
second une tentative déjà faite.

Profondeur réglable (100 / 200 / 500), magasin au choix parmi ceux
réellement présents dans les données. Le classement n'est extrait que
fenêtre ouverte, et tronqué à la profondeur maximale : sans quoi on
copierait 130 000 lignes pour en regarder 500.

Vérifié sur build de production, quatre profils de produits : à Houdemont
seuls remontent les jamais détenus, à Frouard seuls les déjà détenus sans
vente, et le profil « stock sans vente » n'apparaît dans aucun des deux. Les
rangs affichés correspondent aux positions dans le classement trié par CA
réseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 07:04:54 +00:00
LogiFlow dc7f6bbf9f Merge pull request #12 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 08:16:49 +02:00
Claude 06f0bd0513 Prix de vente : tenir compte d'un prix non ventilé par magasin
`cube_pv` a une clé `(artnoid, site)`, mais la base n'en porte qu'une ligne
par article — 428 530 lignes pour 427 857 articles, là où deux magasins
tarifés en donneraient le double. Le prix n'est donc pas ventilé : la
colonne serait restée vide dès qu'on consulte le magasin qui n'a pas la
ligne, alors même que l'article a un prix.

La cellule couvre maintenant les quatre cas : le prix du magasin consulté
quand il existe ; le prix unique quand il n'y a qu'un tarif, avec la mention
« prix unique, non ventilé par magasin » ; rien quand plusieurs prix
coexistent sans concerner ce magasin — celui du voisin n'est pas le sien ;
et, en « tous magasins », le prix commun ou le plus élevé assorti du « ≠ ».

Vérifié sur build de production, quatre articles couvrant les quatre cas,
dans les trois modes de magasin. Le cas décisif : prix porté par le seul
site 292, consulté depuis Houdemont — 12,90 € et la mention, au lieu du
« - » d'avant.

La route de diagnostic est retirée : `cube_pv` est confirmé alimenté et
synchronisé, elle a fait son office.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 8158eda11b Prix de vente : élaguer le code magasin, qui sert de clé
Le code magasin de `cube_pv` sert de clé de recherche côté Grille
(`prixVenteByStore["292"]`). Or la synchronisation Apiflow l'écrit BRUT
(`site: r.Site`), là où elle fait passer celui de `cube_stock` par `safeStr`
— et aucune des deux ne l'élague. Une colonne MSSQL de largeur fixe remonte
complétée d'espaces : la recherche échouerait alors sans rien signaler, et
de la pire façon qui soit, le prix restant visible en « tous magasins » et
vide magasin par magasin.

`TRIM` des deux côtés, plus l'élagage du repli lu depuis le cube de stock.
L'opération ne coûte rien quand il n'y a pas de remplissage, et le doute
n'avait pas de raison de subsister.

La route de diagnostic affiche désormais les valeurs de `site` telles
qu'elles sont stockées, avec leur longueur : de quoi vérifier d'un coup
d'œil que les clés sont bien « 292 » et « 579 », sur trois caractères.

Commentaires SQL déplacés hors du littéral gabarit, comme partout ailleurs
dans le fichier : un accent grave dans un commentaire refermait la chaîne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude c9e1934aae Grille : le prix de vente vient de cube_pv, pas d'article_infosup
La colonne PV s'affichait vide sur toutes les lignes. Le schéma du SaaS
Postgres (dépôt Apiflow, postgres/init.sql) explique pourquoi : la seule
source lue jusqu'ici, `article_infosup.prix_vente_mini`, est une borne de
PARAMÉTRAGE — un prix plancher, à côté de `prix_vente_maxi` et
`pv_conseille` — et non le prix pratiqué.

Le prix de vente vit dans `cube_pv (artnoid, site, pv)`, pendant exact du
`cube_pa` déjà utilisé pour l'achat, rafraîchi chaque nuit depuis le cube
MSSQL `Cube_PV`. `cube_stock` porte le même PV et sert de filet : il ne
couvre que les articles ayant une ligne de stock, mais il est déjà interrogé
par la Grille, une colonne de plus dans le SELECT suffit.

Le prix est propre à chaque MAGASIN. La colonne suit donc le magasin
consulté ; en « tous magasins » elle montre le prix commun, et s'ils
divergent le plus élevé assorti d'un « ≠ » et de l'infobulle qui donne les
deux. Une moyenne afficherait un prix qu'aucune caisse ne pratique, et
retenir silencieusement l'un des deux ferait passer le prix d'un magasin
pour celui des deux.

L'en-tête devient « PV / Magasin » : « PV central » décrivait la fiche
article, ce n'est plus la source.

Vérifié sur build de production, prix identiques, divergents et absents :
en « tous magasins » 14,90 € ≠ avec l'infobulle « Frouard (Nancy) : 13,90 €
· Houdemont : 14,90 € » ; sur Frouard 13,90 €, sur Houdemont 14,90 €, sans
marqueur ; « - » quand aucune source n'a de prix.

La route de diagnostic compte désormais les trois sources côte à côte, de
quoi confirmer sur la base réelle laquelle est renseignée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 9729b00dc7 Diagnostic : d'où vient (ou ne vient pas) le prix de vente
La colonne « PV central » s'affiche vide sur toutes les lignes. Le chemin du
code est pourtant intact : `getProductRows` renseigne `prixVente` depuis
`article_infosup.prix_vente_mini` (phase 3), aucune phase suivante ne
l'efface, et la route de streaming diffuse la ligne entière sans filtrer ses
champs. La valeur est donc absente à la source.

Deux causes possibles, que seule la base peut départager : la colonne existe
mais n'est pas renseignée, ou le prix de vente vit ailleurs sous un autre
nom. `article_infosup.prix_vente_mini` est la seule source de prix de vente
câblée dans l'application — la Grille et la fiche produit la partagent, si
bien qu'une fiche produit affichant « — » sur PV central confirmerait la
première hypothèse.

Cette route de diagnostic répond aux deux questions : remplissage réel de la
colonne actuelle (absente / à zéro / renseignée), globalement puis pour un
fournisseur donné, et énumération des colonnes candidates du schéma AVEC
leur remplissage — une colonne bien nommée mais vide ne servirait à rien.

À supprimer une fois la source établie. Elle est derrière l'authentification
et ne renvoie que des comptages et quelques exemples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
LogiFlow 1e7c1ba4cd Merge pull request #11 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
2026-08-13 22:25:43 +02:00
Claude 52b663579f Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
Deux colonnes s'ajoutent à la suite de la tendance réseau :

  · PV moyen (Réseau) — calculé par l'application, CA réseau ÷ quantité
    vendue, exactement la définition déjà retenue par la fiche produit.
    Sans quantité sur la période, la colonne affiche « - » : le rapport
    n'existe pas, et un prix à 0 € se lirait comme un article donné.
  · PV central (Article) — le prix de vente de la fiche article, lu en base
    (article_infosup.prix_vente_mini), sans retraitement.

Les deux restent deux colonnes. En dériver un écart chiffré supposerait une
assiette commune — HT/TTC, remises — que les deux sources ne garantissent
pas ; le rapprochement se fait à l'œil, en connaissance de cause.

Le bouton de repli du bloc mensuel devient un menu « Vues » à deux entrées,
ventes mensuelles et prix, chacune repliable. Il agit par BLOCS, là où le
menu « Colonnes » voisin agit colonne par colonne : douze cellules
mensuelles ou deux prix ne se masquent pas une par une. Les deux états sont
persistés.

Au passage, le tri des colonnes réseau était faux. La sentinelle ±Infinity
choisie d'après le sens de tri courant ne pouvait pas fonctionner : TanStack
mémorise le résultat de l'accesseur dans `row._valuesCache` et ne le
réévalue jamais, si bien que la valeur calculée au premier tri restait figée
et remontait les lignes sans donnée en tête dès qu'on inversait le sens.
`sortUndefined: "last"`, traité avant l'inversion, les garde en bas dans les
deux sens. Les cinq colonnes réseau existantes en bénéficient, et `sorting`
quitte les dépendances des colonnes : plus de reconstruction complète à
chaque clic d'en-tête.

Vérifié sur build de production : les deux colonnes se placent bien après la
tendance, « - » sur quantité réseau nulle comme sur PV central absent, et le
tri place les absents en bas au premier clic (décroissant) comme au deuxième
(croissant). Menu « Vues » : 31 colonnes avec les deux blocs, 29 sans les
prix, 17 sans rien, 19 avec les prix seuls — chaque fois sur toutes les
lignes montées, sans rechargement, et l'état survit au rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 20:16:36 +00:00
LogiFlow 89d573634a Merge pull request #10 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-13 21:18:31 +02:00
Claude 0a2bc8318b Grille : remonter le corps du tableau au changement de colonnes
Le repli du bloc mensuel ne repeignait toujours pas les lignes déjà
montées : l'en-tête perdait ses douze colonnes, le corps les gardait, et il
fallait recharger la page pour retrouver une grille cohérente.

Le signal passé aux lignes (`columnsKey`) reposait sur la comparaison de
props de `React.memo`. La clé du `<tbody>` porte désormais cette même
signature : un changement de colonnes remonte le corps, sans plus dépendre
d'une comparaison qu'une prop oubliée suffit à mettre en défaut. Le
mécanisme existait déjà pour la densité d'affichage.

Le coût est celui des seules lignes virtualisées (une trentaine), et le
redimensionnement d'une colonne ne déclenche rien : les identifiants de
colonnes, eux, ne changent pas.

Vérifié cette fois sur un BUILD DE PRODUCTION, 2 000 lignes : au repli,
17 en-têtes et 17 cellules pour chaque ligne montée ; toujours 17 après
défilement vers des lignes virtualisées ensuite ; 29 partout au retour, et
17 au repli suivant — sans rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 19:15:15 +00:00
Claude c532386385 Grille : repeindre les lignes montées quand le jeu de colonnes change
Replier le bloc mensuel ne vidait que l'en-tête. Les lignes déjà montées
gardaient leurs douze cellules, seules celles virtualisées ensuite
adoptaient le nouveau jeu — une grille à deux vitesses, en-tête d'un côté,
corps de l'autre.

TanStack met ses objets `Row` en cache sur la seule identité des DONNÉES :
changer les colonnes ne leur donne aucune référence neuve, et `React.memo`
concluait qu'il n'y avait rien à repeindre. Le contournement existait déjà
pour `columnVisibility` et `columnSizing`, passés en props sans jamais être
lus ; le drapeau du bloc mensuel, lui, n'y figurait pas.

`columnVisibility` cède la place à `columnsKey`, la signature des colonnes
réellement visibles : elle change pour TOUT changement de colonnes, case
décochée dans le menu comme bloc replié, là où l'ancien objet ne voyait que
le menu. Calculée une fois par rendu de grille, pas une fois par ligne.

Vérifié en rendant la Grille sur données de démonstration : 29 cellules par
ligne et 29 en-têtes, 17 et 17 une fois le bloc replié, 29 et 29 au retour.
Avec le signal figé, l'en-tête tombe à 17 quand les lignes restent à 29 —
le défaut signalé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 11:13:39 +00:00
LogiFlow 1f4571bd7a Merge pull request #9 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : détail mensuel au clic sur le total, bloc mensuel repliable
2026-08-13 13:00:24 +02:00
Claude 2ae523a804 Grille : détail mensuel au clic sur le total, bloc mensuel repliable
La colonne « Tot. 12m » dit combien, jamais comment. Un clic sur la case
ouvre désormais le détail des 12 mois glissants, empilé dans l'ordre où on
le lit : les ventes mensuelles, puis les entrées en stock, puis le stock de
fin de mois magasin par magasin — un stock de 40 réparti sur deux sites et
un stock de 40 bloqué sur un seul ne se pilotent pas pareil.

Les deux premières séries suivent le magasin actif de la Grille, par
cohérence avec la case cliquée ; la ventilation par magasin reste entière,
c'est son objet. Les barres se dessinent de part et d'autre d'un zéro
explicite : une quantité mensuelle peut être négative (retours supérieurs
aux ventes) et sortirait sinon du cadre par le bas.

Le bloc des douze colonnes mensuelles se replie d'un bouton. L'état est un
drapeau à part, et non douze entrées de `columnVisibility` : masquer douze
colonnes une par une n'est pas une manipulation, et les identifiants
`month_YYYYMM` changent à chaque nouveau mois alors que le drapeau, lui,
survit à la fenêtre glissante. Les colonnes sont retirées du modèle plutôt
que cachées — inutile de faire calculer douze cellules par ligne à TanStack
pour ne rien peindre.

`TuileStat` sort de heatmap-grid.tsx vers un module partagé, la modale de
tendance réseau et la nouvelle modale s'en servant toutes deux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 10:54:24 +00:00
Claude 92769535dc style(grille): agrandit la carte de tendance, illisible en l'état
Les textes du graphique tournaient entre 8,5 et 10 px : titres de séries,
« max N », valeurs annotées, mois de l'axe, infobulle, ainsi que les légendes
des trois tuiles. Difficile à lire.

Typographie relevée d'un cran partout : titres de séries 9,5 → 13, maximum
9 → 11,5, valeurs sur les points 9 → 12,5, mois 8,5 → 11, infobulle 9 → 11,5,
tuiles 10 → 12 avec la valeur à 26 px, tableau 11 → 13.

Le dessin suit la typographie plutôt que de la subir : bandeau de facette
15 → 21, axe 18 → 24, écart 12 → 14, infobulle élargie à 196 avec un interligne
de 17 — sans quoi titres et courbes se seraient chevauchés. La carte passe à
max-w-2xl pour absorber le tout sans comprimer le graphique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 11:43:18 +00:00
Claude a4e3c53dcc feat(grille): détail par magasin dans la carte, en mode tous magasins
En « tous magasins », les trois tuiles de la carte sont des cumuls : savoir que
le stock est de 40 ne dit pas s'il est réparti entre Frouard et Houdemont ou
entièrement sur un seul site — or c'est précisément ce qu'on vient vérifier.

La carte affiche désormais, sous les cumuls, une ligne par magasin avec ses
ventes, ses entrées et son stock de fin de mois. Uniquement en mode tous
magasins : sur un magasin unique, les tuiles sont déjà ce détail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:05:47 +00:00
Claude 6e59458403 fix(grille): grille vide au changement de magasin, et stock TOTAL incomplet
Trois défauts distincts, dont un que je venais d'introduire.

1. RÉGRESSION — grille vide. Le store zustand est persisté dans le navigateur
   sans version ni migration. Le passage de filters.code3 à `string[] | null`
   laissait donc une CHAÎNE dans le localStorage des utilisateurs :
   `new Set("320211")` produit un ensemble de caractères, plus aucune ligne ne
   correspond, et la Grille apparaît vide sans le moindre message. Ajout de
   `version: 1` + `migrate`, plus une garde dans le filtre pour ne jamais
   redevenir muet sur un état inattendu.

2. STOCK « tous magasins » incomplet. Le stock est un NIVEAU, pas un flux : un
   magasin sans mouvement dans le mois détient toujours sa marchandise. Or le
   TOTAL était sommé depuis les lignes mensuelles brutes, donc n'incluait que
   les sites ayant bougé ce mois-là — sur un produit à faible rotation, il
   n'affichait que Frouard. Il est désormais recalculé depuis les séries par
   site, qui sont reportées d'un mois sur l'autre.

3. CHANGEMENT DE MAGASIN qui se fige. Hors « tous magasins », un rattrapage
   interroge l'API FF à raison d'UNE requête HTTP par article : sur un gros
   fournisseur, cela fait des milliers d'appels. Borné à 300 (réglable via
   GRID_STORE_RECONCILE_MAX), avec un avertissement explicite sur ce qui n'a
   pas été rattrapé — pas de troncature silencieuse.

Enfin, getProductRows ne renvoie plus [] en cas d'erreur : une liste vide est
indiscernable d'un fournisseur sans article. La Grille affichait une page
blanche sans explication, et la synchro nocturne prenait la panne pour un
fournisseur vide — qu'elle désactivait automatiquement. L'erreur remonte
désormais jusqu'au bandeau rouge et au statut « echec ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:04:09 +00:00
Claude 720cf3c7a1 feat(grille): filtre nomenclature en multi-sélection
Le filtre était un <select> à valeur unique : impossible de retenir plusieurs
nomenclatures, ni de partir de tout pour retirer les postes non voulus — ce qui
est la manière naturelle de travailler quand un fournisseur en compte des
dizaines.

Remplacé par une liste déroulante à cases, avec recherche, « Tout cocher »,
« Tout décocher », « Ne garder que ces N » sur le résultat d'une recherche, et
une croix pour tout réafficher. Décocher depuis l'état « tout » matérialise la
liste complète puis en retire le poste, sinon le premier décochage n'aurait
rien fait.

filters.code3 passe de `string | null` à `string[] | null` : `null` = aucun
filtre, `[]` = rien de coché. Distinguer les deux évite la valeur sentinelle
qu'imposerait un simple tableau, et rend « Tout décocher » sans ambiguïté.
L'URL accepte une liste séparée par des virgules.

Au passage : la Grille rechargeait tout depuis le serveur à chaque changement
de nomenclature, alors que getProductRows ignore ce filtre et que le tri se
fait en local. Le paramètre n'est plus transmis ni dans les dépendances de
l'effet — le filtrage est désormais instantané, ce qui compte d'autant plus
avec plusieurs cases à cocher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 08:46:39 +00:00
Claude 0bd7ec9798 feat(admin): synchronisation nocturne des fournisseurs, paramétrable
Page /admin/synchronisation : choisir quels fournisseurs sont cadencés, pour
quelle source, et suivre l'avancement.

ORDRE DE PASSAGE. La file est « le plus anciennement synchronisé d'abord », en
ignorant ceux déjà traités depuis l'ouverture de la fenêtre. Il en découle,
sans curseur ni compteur de tour : la nuit 2 reprend là où la nuit 1 s'est
arrêtée, le tour recommence quand tout le monde est passé, et un fournisseur
en échec garde une date ancienne donc repasse en tête.

DEUX FILES. SQL (~43 s sur le plus gros) et Qlik (jusqu'à 8 min, sur un serveur
qui abandonne quand on le sollicite trop) ont leur propre activation et leur
propre rythme. Une extraction Qlik est intercalée tous les N fournisseurs SQL,
sinon elle ne démarrerait jamais avant que la file SQL soit vide. Un plafond
par nuit et un âge minimal évitent de refaire chaque nuit des agrégats
mensuels.

Détails qui comptent :
- La fenêtre signifie « ne plus DÉMARRER après l'heure de fin » : une
  extraction en cours n'est jamais coupée. Le passage par minuit est géré.
- L'horodatage est écrit même en cas d'échec, sans quoi le fournisseur fautif
  bloquerait la file derrière lui.
- Garde anti-boucle : l'avancement repose sur cette écriture, volontairement
  non bloquante ; si elle échoue, le round s'arrête au lieu de reprendre le
  même fournisseur indéfiniment.
- Un fournisseur sans article est désactivé automatiquement, motif affiché,
  réactivable — plutôt que réessayé chaque nuit.
- Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
  Qlik la nuit suivante sans décision explicite.

Déclenchement par minuteur interne (instrumentation.ts), battement d'une
minute. Aucune configuration côté hôte, se réarme au démarrage ; en
contrepartie rien ne tourne si l'application est arrêtée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:28:11 +00:00
Claude be8d576af5 chore: supprime les derniers restes de l'IA dans les Paramètres
La section « IA Copilot — OpenRouter » annonçait « pour les analyses de
gammes », mais aucun code ne consommait cette clé : elle était décorative.
Supprimée, ainsi que les routes devenues orphelines /api/admin/ai-config,
/api/google-ai/models et /api/openrouter/models.

Nettoyage de l'état correspondant dans la page : clé, liste de modèles,
fournisseur d'IA et modèle Google ne faisaient plus qu'un aller-retour entre le
chargement et l'enregistrement, sans aucune interface pour les modifier.
handleSave n'enregistre donc plus que l'URL de la base.

La règle set-state-in-effect s'est mise à signaler le `setIsMounted` du montage,
inchangé mais devenu analysable une fois le code IA retiré : dérogation locale,
comme dans api-connection-info.tsx, puisque poser l'indicateur au montage est
précisément ce qui évite l'écart d'hydratation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:06:23 +00:00
Claude fff4cd9641 chore: retire la section Score des Paramètres et supprime la page Chat IA
- Section « Score Produit » retirée : elle ne faisait que décrire une formule,
  sans rien paramétrer.
- Page /admin/ai-chat et sa route /api/admin/ai-chat supprimées, ainsi que
  l'entrée de menu et la section « Admin AI Chat — Fournisseur » des
  Paramètres, qui n'existait que pour la configurer.
- Nettoyage de l'état devenu mort dans la page Paramètres : la liste des
  modèles Google et son chargement n'avaient plus d'interface.

Effet de bord notable : tsc passe de 23 à 4 erreurs préexistantes — la page de
chat en portait 19 à elle seule (flags de regex exigeant une cible es2018).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:56:24 +00:00
Claude 310165151d fix(qlik): reprise globale quand le moteur abandonne l'extraction (code 15)
Deux exécutions du même fournisseur D005, même code, même jour :
  04:17 → OpenDoc en 78 s,  succès en 479 s
  06:13 → OpenDoc en 233 s, « code 15 / Request aborted » après 333 s

Le moteur Qlik est trois fois plus lent à 6 h qu'à 4 h et finit par abandonner
la requête. Les reprises par requête de rpcWithRetry ne servent à rien ici :
c'est toute l'extraction qui tombe, et la sync repartait à zéro sans réessayer.

L'extraction est désormais relancée entièrement sur erreur transitoire (code 15
ou « Request aborted »), une fois par défaut, après une pause de 60 s — pas
immédiatement : relancer aussitôt ne ferait qu'ajouter de la charge au serveur
qui vient de renoncer. Réglable via QLIK_EXTRACTION_RETRIES et
QLIK_EXTRACTION_RETRY_PAUSE_MS.

Le reste du comportement est préservé : le refus de publier une fenêtre 12 mois
incomplète, et la reprise sur point de contrôle hors fenêtre datée, s'appliquent
comme avant une fois les reprises épuisées. Le checkpoint est vidé entre deux
tentatives pour ne pas mélanger deux extractions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:24:09 +00:00
Claude 0a57a9a707 fix(api): nomenclatures dérivées du préfixe de code3, pas de code1/code2
Les codes de nomenclature FF font 6 chiffres et sont hiérarchiques par préfixe
(31xxxx à 40xxxx) : « 32 » univers, « 3202 » famille, « 320211 » sous-famille.

L'endpoint groupait sur les colonnes code1/code2, qui ne sont renseignées que
si pgGetNomenclatureByFournisseur a su remonter la hiérarchie — ce qui dépend
d'une détection heuristique de la colonne parent de la table `nomenclature`.
Quand elle échoue, seul code3 est rempli et l'endpoint aurait renvoyé une liste
vide aux niveaux 1 et 2, sans rien signaler.

Les niveaux sont désormais dérivés de left(code3, 2|4|6), toujours disponible.

En conséquence, /grid gagne un filtre `nomenclature` par préfixe, qui fonctionne
aux trois niveaux et ne dépend pas non plus de code1/code2 : c'est lui que
meta.filtreGrid désigne. Les filtres code1..code3 exacts restent inchangés.

Les libellés viennent toujours du payload et peuvent être null aux niveaux 1 et
2 pour la même raison ; codes et compteurs, eux, sont exacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:08:02 +00:00
Claude 60aa2ce120 feat(api): inventaire des nomenclatures, pour découper un gros fournisseur
Les filtres code1/code2/code3 existaient déjà sur /grid, mais un appelant ne
pouvait pas savoir quelles valeurs existent chez un fournisseur : il aurait dû
tout télécharger pour les découvrir, ce qui annulait l'intérêt du découpage.

GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… renvoie les
postes avec leur nombre d'articles et leur CA, agrégés en SQL — quelques
dizaines de lignes, même pour un fournisseur de 130 000 articles. `parent`
permet de descendre l'arborescence sans tout charger, et meta.filtreGrid
indique le paramètre de /grid correspondant au niveau demandé.

Le parcours devient : lister les postes, puis /grid?fournisseur=…&code1=… poste
par poste. Bien plus praticable pour une IA que 132 000 lignes d'un bloc, et
plus pertinent métier que le découpage par gamme.

Les libellés sont lus dans le payload jsonb (ils n'ont pas de colonne dédiée).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:00:54 +00:00
Claude 8fe294924b fix(api): tient les très gros fournisseurs (132k articles)
Le journal du fournisseur D005 montre 132 388 lignes persistées pour un seul
fournisseur. À cette échelle, /api/v1/grid sans limit ne tenait pas.

Deux plantages francs, indépendants de la taille de la réponse :
- getNetworkMetricsByCodeCentrale : inArray avec 132 000 valeurs, soit autant
  de paramètres liés, alors que le protocole PostgreSQL en accepte 65535.
- pgGetGammesByCodeins : même problème sur son IN (…).
Les deux lectures sont désormais découpées par lots de 2000. Le bug existait
pour tout appel dépassant ~65 000 codes, indépendamment de l'API.

Volume : charger 132k lignes complètes puis les sérialiser d'un bloc représente
plusieurs centaines de Mo en mémoire. Sans `limit`, la réponse est maintenant
diffusée en flux, par lots internes de 1000, à mémoire constante. La forme
{ data, pagination, meta } est inchangée — un client existant ne voit que des
octets arrivant progressivement.

Une erreur survenant après les premiers octets ne peut plus changer le code
HTTP : le document est alors fermé avec meta.erreur et meta.complet=false, pour
qu'une réponse tronquée ne passe pas pour complète.

`fields` reste le levier décisif à cette échelle : une ligne complète porte les
séries mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 05:45:49 +00:00
Claude 33b30dc8bd fix(api): supprime le plafond de 500 lignes, qui tronquait en silence
Le passage à limit=5000 du commit précédent était sans effet : queryGridRows
re-plafonnait à 500 en dur (Math.min(500, …)). Pire, la pagination était
calculée sur le limit *demandé*, donc hasMore et meta.complet annonçaient une
réponse complète alors qu'elle était tronquée — exactement la troncature
silencieuse que ces champs devaient éliminer.

- Plus aucun plafond. Sur /grid, omettre `limit` renvoie toutes les lignes du
  fournisseur : plus de pagination à dérouler, ce que les agents font mal.
- La pagination reflète le mode sans limite (une page couvrant le total), donc
  hasMore et meta.complet disent la vérité.
- Le défaut reste 100 sur les endpoints de recherche, non bornés par un
  fournisseur — mais sans maximum imposé.
- meta.avertissement explique désormais que la troncature vient du `limit`
  fourni, et qu'il suffit de l'omettre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:46:32 +00:00
Claude 73accd6d60 feat(api): un seul appel suffit pour tout un fournisseur
Le plafond de pagination était de 500 lignes. Un lot fournisseur dépasse
souvent ce seuil, si bien qu'une app ou un agent ne récupérait qu'une partie
des données sans forcément s'en rendre compte — et les agents paginent mal,
concluant volontiers qu'ils ont tout lu.

- /grid accepte désormais limit jusqu'à 5000. La borne reste à 500 sur la
  recherche transversale, qui n'est pas bornée par un fournisseur.
- pagination.hasMore, et meta.complet sur /grid : l'appelant sait s'il a tout,
  sans avoir à comparer page et totalPages.
- Réponse partielle : meta.avertissement indique le nombre de lignes obtenues
  sur le total et comment obtenir le reste. Pas de troncature silencieuse.
- Doc des Paramètres et openapi.json mis à jour, avec un exemple curl qui
  récupère un fournisseur entier.

`fields` reste vivement conseillé : une ligne complète porte les séries
mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:30:25 +00:00
46 changed files with 4273 additions and 2029 deletions

No files matched your search

+25
View File
@@ -178,6 +178,31 @@ async function main() {
`);
console.log("[DB Init] Table api_keys is verified/created.");
// Paramétrage et suivi de la synchronisation nocturne, par fournisseur.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"nom_fournisseur" varchar(255),
"actif_sql" boolean NOT NULL DEFAULT true,
"actif_qlik" boolean NOT NULL DEFAULT true,
"desactive_motif" text,
"dernier_sql_at" timestamp,
"dernier_sql_statut" varchar(20),
"dernier_sql_lignes" integer,
"dernier_sql_erreur" text,
"dernier_sql_ms" integer,
"dernier_qlik_at" timestamp,
"dernier_qlik_statut" varchar(20),
"dernier_qlik_codes" integer,
"dernier_qlik_erreur" text,
"dernier_qlik_ms" integer,
"updated_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
`);
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
-528
View File
@@ -1,528 +0,0 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Strip HTML tags and convert common HTML constructs to Markdown equivalents
function htmlToMarkdown(text: string): string {
return text
// Block-level → markdown
.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`)
.replace(/<strong[^>]*>(.*?)<\/strong>/gis, "**$1**")
.replace(/<b[^>]*>(.*?)<\/b>/gis, "**$1**")
.replace(/<em[^>]*>(.*?)<\/em>/gis, "*$1*")
.replace(/<i[^>]*>(.*?)<\/i>/gis, "*$1*")
.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`")
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```")
.replace(/<li[^>]*>(.*?)<\/li>/gis, "- $1")
.replace(/<ul[^>]*>|<\/ul>/gis, "")
.replace(/<ol[^>]*>|<\/ol>/gis, "")
.replace(/<tr[^>]*>/gis, "")
.replace(/<\/tr>/gis, "\n")
.replace(/<th[^>]*>(.*?)<\/th>/gis, "| **$1** ")
.replace(/<td[^>]*>(.*?)<\/td>/gis, "| $1 ")
.replace(/<table[^>]*>|<\/table>|<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gis, "")
.replace(/<br\s*\/?>/gis, "\n")
.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n")
.replace(/<div[^>]*>(.*?)<\/div>/gis, "$1\n")
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)")
// Strip any remaining tags
.replace(/<[^>]+>/g, "")
// Decode HTML entities
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
// Clean up excessive blank lines
.replace(/\n{3,}/g, "\n\n")
.trim();
}
interface ModelInfo {
id: string;
name: string;
free: boolean;
promptPrice: number; // $ per token
completionPrice: number;
}
interface ToolEvent {
name: string;
args: Record<string, unknown>;
result?: string;
}
interface Message {
role: "user" | "assistant";
content: string;
toolEvents?: ToolEvent[];
}
interface SessionUsage {
promptTokens: number;
completionTokens: number;
costUsd: number;
}
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
// Compute cost from token counts and model pricing
function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number {
if (!model) return 0;
return promptTokens * model.promptPrice + completionTokens * model.completionPrice;
}
function formatCost(usd: number): string {
if (usd === 0) return "Gratuit";
if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`;
return `$${usd.toFixed(6)}`;
}
function ToolCallBlock({ event }: { event: ToolEvent }) {
const [open, setOpen] = useState(false);
const label = event.name === "execute_sql"
? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}`
: event.name === "get_db_schema"
? "Schéma DB"
: event.name;
return (
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
>
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
</button>
{open && event.result && (
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
{event.result}
</pre>
)}
</div>
);
}
function MessageBubble({ msg }: { msg: Message }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
{/* Avatar */}
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
)}>
{isUser
? <User className="w-4 h-4" />
: <Bot className="w-4 h-4 text-[var(--accent)]" />
}
</div>
{/* Content */}
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
{/* Tool events (assistant only) */}
{msg.toolEvents && msg.toolEvents.length > 0 && (
<div className="w-full mb-2">
{msg.toolEvents.map((ev, i) => (
<ToolCallBlock key={i} event={ev} />
))}
</div>
)}
{/* Text bubble */}
{msg.content && (
<div className={cn(
"rounded-2xl px-4 py-3 text-sm leading-relaxed break-words",
isUser
? "bg-[var(--accent)] text-white rounded-tr-md"
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
)}>
{isUser ? msg.content : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
h1: ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li className="ml-2">{children}</li>,
code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) =>
inline
? <code className="px-1 py-0.5 rounded bg-[var(--bg-base)] border border-[var(--border)] font-mono text-xs text-[var(--accent)]">{children}</code>
: <pre className="my-2 p-3 rounded-lg bg-[var(--bg-base)] border border-[var(--border)] overflow-x-auto"><code className="font-mono text-xs text-[var(--text-primary)] whitespace-pre">{children}</code></pre>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)] pl-3 my-2 text-[var(--text-secondary)] italic">{children}</blockquote>,
table: ({ children }) => <div className="overflow-x-auto my-2"><table className="w-full text-xs border-collapse">{children}</table></div>,
thead: ({ children }) => <thead className="bg-[var(--bg-base)]">{children}</thead>,
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1">{children}</td>,
hr: () => <hr className="border-[var(--border)] my-3" />,
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent)] underline hover:opacity-80">{children}</a>,
}}
>
{htmlToMarkdown(msg.content)}
</ReactMarkdown>
)}
</div>
)}
</div>
</div>
);
}
export default function AdminAiChatPage() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingModels, setLoadingModels] = useState(true);
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
// Fetch config + models
useEffect(() => {
setLoadingModels(true);
fetch("/api/admin/ai-config")
.then(r => r.json())
.then(async d => {
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
setAiProviderState(provider);
if (provider === "google") {
const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] }));
setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({
id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0,
})));
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
} else {
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
if (od.models) setModels(od.models);
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
}
})
.catch(async () => {
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
if (od.models) setModels(od.models);
})
.finally(() => setLoadingModels(false));
}, []);
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loading]);
const sendMessage = useCallback(async () => {
if (!input.trim() || loading) return;
const userMsg: Message = { role: "user", content: input.trim() };
setInput("");
setError(null);
setMessages(prev => [...prev, userMsg]);
setLoading(true);
// Build messages to send (excluding tool events, which are local display only)
const apiMessages = [...messages, userMsg].map(m => ({
role: m.role,
content: m.content,
}));
const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] };
setMessages(prev => [...prev, assistantMsg]);
abortRef.current = new AbortController();
try {
const res = await fetch("/api/admin/ai-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: apiMessages, model: selectedModel }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Erreur serveur");
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
const pendingToolEvents: ToolEvent[] = [];
let lastToolStart: ToolEvent | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
try {
const event = JSON.parse(raw);
if (event.type === "text") {
setMessages(prev => {
const copy = [...prev];
const last = { ...copy[copy.length - 1] };
last.content += event.content;
copy[copy.length - 1] = last;
return copy;
});
} else if (event.type === "tool_start") {
lastToolStart = { name: event.name, args: event.args };
} else if (event.type === "tool_result") {
if (lastToolStart) {
const ev: ToolEvent = { ...lastToolStart, result: event.result };
pendingToolEvents.push(ev);
lastToolStart = null;
setMessages(prev => {
const copy = [...prev];
const last = { ...copy[copy.length - 1] };
last.toolEvents = [...pendingToolEvents];
copy[copy.length - 1] = last;
return copy;
});
}
} else if (event.type === "usage") {
const pt = event.prompt_tokens ?? 0;
const ct = event.completion_tokens ?? 0;
const cost = computeCost(pt, ct, selectedModelInfo);
setSessionUsage(prev => ({
promptTokens: prev.promptTokens + pt,
completionTokens: prev.completionTokens + ct,
costUsd: prev.costUsd + cost,
}));
} else if (event.type === "error") {
setError(event.message);
}
} catch { /* ignore */ }
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
setError(err instanceof Error ? err.message : "Erreur inconnue");
}
} finally {
setLoading(false);
}
}, [input, loading, messages, selectedModel, selectedModelInfo]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const clearConversation = () => {
setMessages([]);
setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
setError(null);
if (loading && abortRef.current) {
abortRef.current.abort();
setLoading(false);
}
};
return (
<div className="flex flex-col h-full max-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-4 shrink-0">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Bot className="w-5 h-5 text-[var(--accent)]" />
Admin AI Chat
</h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
Accès direct à la base de données · Admin uniquement
</p>
</div>
{/* Usage / Cost */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
<span className="text-[var(--text-muted)]">session</span>
{sessionUsage.promptTokens > 0 && (
<span className="text-[var(--text-muted)] ml-1">
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
</span>
)}
</div>
<button
onClick={clearConversation}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Effacer
</button>
</div>
</div>
{/* Model selector */}
<div className="mb-4 shrink-0">
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
aiProvider === "google"
? "bg-blue-500/10 text-blue-500"
: "bg-[var(--accent-bg)] text-[var(--accent)]"
)}>
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
</span>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Chargement des modèles…
</div>
) : (
<div className="flex items-center gap-3 flex-1 min-w-0">
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.name} {m.free ? "(Gratuit)" : ""}
</option>
))}
</select>
{selectedModelInfo && !selectedModelInfo.free && (
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
<span>·</span>
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
</div>
)}
{selectedModelInfo?.free && (
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<Bot className="w-8 h-8 text-[var(--accent)]" />
</div>
<div>
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
<p className="text-sm text-[var(--text-muted)] max-w-md">
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
</p>
</div>
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
{[
"Combien de produits par fournisseur ?",
"Quels sont les 10 produits avec le plus de CA ?",
"Montre-moi les snapshots récents",
"Répartition des gammes A/B/C/D/Z",
].map(suggestion => (
<button
key={suggestion}
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{loading && messages[messages.length - 1]?.role !== "assistant" && (
<div className="flex gap-3 mb-4">
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
<Bot className="w-4 h-4 text-[var(--accent)]" />
</div>
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
{error && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="mt-3 shrink-0">
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
rows={1}
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
style={{ minHeight: "40px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 128) + "px";
}}
disabled={loading}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || loading}
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
>
{loading
? <Loader2 className="w-4 h-4 animate-spin" />
: <Send className="w-4 h-4" />
}
</button>
</div>
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
L&apos;IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
</p>
</div>
</div>
);
}
@@ -0,0 +1,414 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
CheckCircle, Database, Network, Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface SyncSettings {
actif: boolean;
heureDebut: number;
heureFin: number;
qlikParNuit: number;
sqlParQlik: number;
qlikMinJours: number;
}
interface SyncState {
enCours: boolean;
origine: "auto" | "manuel" | null;
debutAt: string | null;
finAt: string | null;
fournisseurCourant: string | null;
etape: "sql" | "qlik" | null;
sqlFaits: number;
sqlEchecs: number;
qlikFaits: number;
qlikEchecs: number;
tourTermine: boolean;
derniereErreur: string | null;
}
interface FournisseurRow {
codeFournisseur: string;
nomFournisseur: string | null;
actifSql: boolean;
actifQlik: boolean;
desactiveMotif: string | null;
dernierSqlAt: string | null;
dernierSqlStatut: string | null;
dernierSqlLignes: number | null;
dernierSqlMs: number | null;
dernierQlikAt: string | null;
dernierQlikStatut: string | null;
dernierQlikCodes: number | null;
}
interface Resume {
total: number; actifsSql: number; actifsQlik: number;
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
}
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais";
const fmtDuree = (ms: number | null) =>
ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`;
function Pastille({ statut }: { statut: string | null }) {
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
const couleurs: Record<string, string> = {
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
};
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
}
export function SynchronisationClient() {
const [settings, setSettings] = useState<SyncSettings | null>(null);
const [state, setState] = useState<SyncState | null>(null);
const [dansLaFenetre, setDansLaFenetre] = useState(false);
const [rows, setRows] = useState<FournisseurRow[]>([]);
const [resume, setResume] = useState<Resume | null>(null);
const [erreur, setErreur] = useState<string | null>(null);
const [chargement, setChargement] = useState(true);
const [recherche, setRecherche] = useState("");
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
const lireEtat = useCallback(async () => {
const res = await fetch("/api/admin/sync");
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
return res.json();
}, []);
const lireFournisseurs = useCallback(async () => {
const res = await fetch("/api/admin/sync/fournisseurs");
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
return res.json();
}, []);
const rafraichirEtat = useCallback(() => {
lireEtat()
.then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); })
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)));
}, [lireEtat]);
const rafraichirTout = useCallback(() => {
Promise.all([lireEtat(), lireFournisseurs()])
.then(([e, f]) => {
setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre);
setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null);
})
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)))
.finally(() => setChargement(false));
}, [lireEtat, lireFournisseurs]);
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
useEffect(() => {
if (!state?.enCours) return;
const id = setInterval(rafraichirEtat, 3000);
return () => clearInterval(id);
}, [state?.enCours, rafraichirEtat]);
const majSettings = async (patch: Partial<SyncSettings>) => {
setErreur(null);
try {
const res = await fetch("/api/admin/sync", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
setSettings(d.settings);
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
};
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
setErreur(null);
try {
const res = await fetch("/api/admin/sync", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
rafraichirTout();
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
};
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
setErreur(null);
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
// un rechargement complet à chaque case cochée serait pénible.
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) }
: r)));
try {
const res = await fetch("/api/admin/sync/fournisseurs", {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codes, [champ]: valeur }),
});
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
} catch (e) {
setErreur(e instanceof Error ? e.message : String(e));
rafraichirTout(); // l'optimisme était infondé : on resynchronise
}
};
const visibles = useMemo(() => {
const q = recherche.trim().toLowerCase();
return rows.filter((r) => {
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
if (filtre === "actifs") return r.actifSql || r.actifQlik;
if (filtre === "inactifs") return !r.actifSql && !r.actifQlik;
if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
return true;
});
}, [rows, recherche, filtre]);
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
return (
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
<div className="mx-auto max-w-screen-xl">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
Synchronisation nocturne
</h1>
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre
se referme avant la fin, la nuit suivante reprend là où on s&apos;est arrêté ; quand tout le
monde est passé, un nouveau tour recommence.
</p>
{erreur && (
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{erreur}</span>
</div>
)}
{/* ── Réglages ── */}
{settings && (
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
<div className="flex flex-wrap items-end gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={settings.actif}
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
Synchronisation nocturne active
</span>
</label>
<div className="flex items-end gap-2">
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
<input type="number" min={0} max={23} value={settings.heureDebut}
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
<input type="number" min={0} max={23} value={settings.heureFin}
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
<input type="number" min={0} max={200} value={settings.qlikParNuit}
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
<input type="number" min={0} max={365} value={settings.qlikMinJours}
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div className="flex-1" />
{state?.enCours ? (
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
<Square className="w-3.5 h-3.5" /> Arrêter
</button>
) : (
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
style={{ background: "var(--accent)" }}>
<Play className="w-3.5 h-3.5" /> Lancer maintenant
</button>
)}
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
{dansLaFenetre
? "Nous sommes actuellement dans la fenêtre."
: "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."}
{" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu&apos;elle
monopolise la fenêtre. Un fournisseur déjà démarré n&apos;est jamais interrompu à l&apos;heure de fin.
</p>
</div>
)}
{/* ── Round en cours ── */}
{state && (state.enCours || state.finAt) && (
<div className="rounded-2xl p-4 mb-4 text-[13px]"
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
<div className="flex items-center gap-2 flex-wrap">
{state.enCours
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
</span>
{state.enCours && state.fournisseurCourant && (
<span style={{ color: "var(--text-secondary)" }}>
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
</div>
{state.derniereErreur && (
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
)}
</div>
)}
{/* ── Résumé ── */}
{resume && (
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
{[
{ l: "Fournisseurs", v: resume.total },
{ l: "Actifs SQL", v: resume.actifsSql },
{ l: "Actifs Qlik", v: resume.actifsQlik },
{ l: "Jamais synchronisés", v: resume.jamaisSql },
{ l: "Désactivés auto", v: resume.desactivesAuto },
{ l: "En échec", v: resume.enEchecSql + resume.enEchecQlik },
].map((s) => (
<span key={s.l} className="rounded-lg px-2.5 py-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
</span>
))}
</div>
)}
{/* ── Filtres et actions de masse ── */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} placeholder="Code ou nom"
className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
<button key={f} onClick={() => setFiltre(f)}
className="rounded-full px-3 py-1 text-[12px] font-semibold"
style={{
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
color: filtre === f ? "#fff" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}>
{f === "echecs" ? "en échec" : f}
</button>
))}
<div className="flex-1" />
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Activer SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver Qlik
</button>
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<RefreshCw className="w-3 h-3" /> Actualiser la liste
</button>
</div>
{/* ── Tableau ── */}
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
{chargement ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
</div>
) : visibles.length === 0 ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{visibles.map((r) => (
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
{r.nomFournisseur ?? r.codeFournisseur}
</div>
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierSqlStatut} />{" "}
{fmtDate(r.dernierSqlAt)}
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierQlikStatut} />{" "}
{fmtDate(r.dernierQlikAt)}
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
</td>
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
{r.desactiveMotif}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { SynchronisationClient } from "./client";
export const metadata = {
title: "Synchronisation nocturne — CollectFlow",
};
/**
* 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.
*/
export default function SynchronisationPage() {
return <SynchronisationClient />;
}
+2 -1
View File
@@ -19,7 +19,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
const filters = {
code1: params.code1 || null,
code2: params.code2 || null,
code3: params.code3 || null,
// Liste séparée par des virgules : le filtre accepte plusieurs nomenclatures.
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
};
// 1. Fetch available suppliers & stores
+5 -217
View File
@@ -13,8 +13,6 @@ import { ApiConnectionInfo } from "@/features/admin/components/api-connection-in
import { GridWarmup } from "@/features/admin/components/grid-warmup";
import { ServerLogs } from "@/features/settings/components/server-logs";
interface OpenRouterModel { id: string; name: string; free: boolean; }
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="apple-card">
@@ -251,22 +249,9 @@ export default function SettingsPage() {
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const [showKey, setShowKey] = useState(false);
const [apiKey, setApiKey] = useState("");
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [dbError, setDbError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
// AI Provider
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
const [googleAiKey, setGoogleAiKey] = useState("");
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [googleAiModel, setGoogleAiModel] = useState("");
const [googleModels, setGoogleModels] = useState<{ id: string; name: string }[]>([]);
const [googleModelsStatus, setGoogleModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const [isMounted, setIsMounted] = useState(false);
@@ -280,34 +265,6 @@ export default function SettingsPage() {
getDatabaseUrl
} = useDbSettingsStore();
const fetchGoogleModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setGoogleModelsStatus("loading");
try {
const res = await fetch("/api/google-ai/models", { headers: { "x-google-ai-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setGoogleModels(data.models ?? []);
setGoogleModelsStatus("ok");
} catch {
setGoogleModelsStatus("error");
}
}, []);
const fetchModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setModelsStatus("loading");
try {
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setModels(data.models ?? []);
setModelsStatus("ok");
} catch {
setModelsStatus("error");
}
}, []);
const reloadFromServer = useCallback(async () => {
const config = await getSavedDatabaseConfig();
if (config) {
@@ -325,23 +282,13 @@ export default function SettingsPage() {
console.error("Failed to parse saved URL", e);
}
}
if (config.openRouterKey) {
setApiKey(config.openRouterKey);
fetchModels(config.openRouterKey);
}
if (config.openRouterModel) {
setSelectedModel(config.openRouterModel);
}
if (config.aiProvider) setAiProvider(config.aiProvider);
if (config.googleAiKey) {
setGoogleAiKey(config.googleAiKey);
fetchGoogleModels(config.googleAiKey);
}
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
}
}, [fetchModels, fetchGoogleModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé
// au montage, ce que la règle déconseille en général mais qui est ici le but.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
reloadFromServer();
}, [reloadFromServer]);
@@ -362,7 +309,7 @@ export default function SettingsPage() {
const handleSave = async () => {
setSaveStatus("saving");
const url = getDatabaseUrl();
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
const res = await saveDatabaseSettings(url);
if (res.success) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2500);
@@ -477,144 +424,6 @@ export default function SettingsPage() {
<QlikSettingsSection />
{/* AI Provider selector */}
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
<div className="flex gap-2">
<button
onClick={() => setAiProvider("openrouter")}
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
>
OpenRouter
</button>
<button
onClick={() => setAiProvider("google")}
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
>
Google AI Studio
</button>
</div>
{aiProvider === "google" && (
<>
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showGoogleKey ? "text" : "password"}
placeholder="AIzaSy..."
value={googleAiKey}
onChange={(e) => setGoogleAiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowGoogleKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
>
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchGoogleModels(googleAiKey)}
disabled={!googleAiKey || googleModelsStatus === "loading"}
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
>
{googleModelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle Google${googleModels.length > 0 ? ` (${googleModels.length} disponibles)` : ""}`}
hint={googleModelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : googleModelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
{googleModels.length > 0 ? (
<select
value={googleAiModel}
onChange={(e) => setGoogleAiModel(e.target.value)}
className="apple-input"
>
{googleModels.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
) : (
<input
type="text"
value={googleAiModel}
onChange={(e) => setGoogleAiModel(e.target.value)}
placeholder="ex: gemini-2.0-flash"
className="apple-input"
/>
)}
</Field>
</>
)}
{aiProvider === "openrouter" && (
<p className="text-[12px] text-[var(--text-muted)]">
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
</p>
)}
</Section>
{/* OpenRouter */}
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
>
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
{models.length > 0 ? (
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="apple-input"
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))}
</select>
) : (
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="ex: google/gemini-2.0-flash-001"
className="apple-input"
/>
)}
</Field>
</Section>
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
@@ -650,27 +459,6 @@ export default function SettingsPage() {
</div>
</Section>
{/* Score Produit */}
<Section title="Score Produit" subtitle="Formule hybride absolue + relative (v4)">
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 space-y-2">
<p className="text-[11px] text-[var(--text-secondary)]">
<strong>Formule :</strong> Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)
</p>
<p className="text-[11px] text-[var(--text-muted)]">
Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.
</p>
<div className="border-t border-[var(--border-subtle)] pt-2 mt-2 space-y-1">
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Volume</strong> (0-50 pts) — unités / magasin / mois</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>CA</strong> (0-30 pts) — € / magasin / an</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Marge</strong> (0-20 pts) — taux de marge %</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Bonus relatif</strong> (±10 pts) — percentile composite dans le lot</p>
</div>
<p className="text-[11px] text-[var(--text-muted)] mt-2">
Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.
</p>
</div>
</Section>
{/* Journaux serveur — diagnostic des extractions Qlik */}
<Section
title="Journaux serveur"
+460 -400
View File
@@ -1,12 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react";
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Download,
Search,
} from "lucide-react";
import LoadingModal from "@/components/shared/loading-modal";
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
type SortDir = "asc" | "desc";
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
const PAGE_SIZE = 50;
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
function fmtDate(raw: string | null) {
if (!raw) return "—";
const d = new Date(raw);
@@ -21,9 +38,66 @@ function getYear(raw: string | null): string {
return String(d.getFullYear());
}
function SortIcon<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) {
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
return sortDir === "asc"
/**
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
* Évite le clignotement du modal sur les transitions instantanées.
*/
function useDelayedFlag(active: boolean, delay = 120) {
const [elapsed, setElapsed] = useState(false);
const [wasActive, setWasActive] = useState(active);
// Réinitialisation pendant le rendu (et non dans un effet) : dès que
// l'attente cesse, le prochain délai repart de zéro.
if (wasActive !== active) {
setWasActive(active);
if (!active) setElapsed(false);
}
useEffect(() => {
if (!active) return;
const id = setTimeout(() => setElapsed(true), delay);
return () => clearTimeout(id);
}, [active, delay]);
return active && elapsed;
}
// ---------------------------------------------------------------------------
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
// ---------------------------------------------------------------------------
interface Column<T> {
key: Extract<keyof T, string>;
label: string;
/** Tri numérique au lieu du tri alphabétique. */
numeric?: boolean;
render?: (row: T) => ReactNode;
}
interface ExcelSpec<T> {
sheet: string;
filePrefix: string;
header: string[];
widths: number[];
row: (r: T) => (string | number)[];
}
interface StockTableProps<T> {
rows: T[];
magasin: string;
columns: Column<T>[];
fournisseurOf: (r: T) => string;
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
anneeOf?: (r: T) => string;
anneeLabel?: string;
searchIn: (r: T) => (string | null | undefined)[];
emptyLabel: string;
excel: ExcelSpec<T>;
}
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
return dir === "asc"
? <ChevronUp className="inline w-3 h-3 ml-1" />
: <ChevronDown className="inline w-3 h-3 ml-1" />;
}
@@ -49,95 +123,174 @@ function FilterSelect({ label, value, onChange, options }: {
);
}
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
const [sortDir, setSortDir] = useState<SortDir>("asc");
function Pagination({ page, totalPages, total, from, to, onChange }: {
page: number;
totalPages: number;
total: number;
from: number;
to: number;
onChange: (p: number) => void;
}) {
if (total === 0) return null;
function handleSort(key: keyof T) {
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
return (
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
<span className="text-xs text-gray-500">
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
{" à "}
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
{" sur "}
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
</span>
<div className="flex items-center gap-1.5">
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
<ChevronsLeft className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-gray-600 tabular-nums">
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
</span>
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
<ChevronRight className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
function StockTable<T extends object>({
rows,
magasin,
columns,
fournisseurOf,
anneeOf,
anneeLabel = "Année entrée",
searchIn,
emptyLabel,
excel,
}: StockTableProps<T>) {
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [page, setPage] = useState(1);
const [exporting, setExporting] = useState(false);
const fournisseurs = useMemo(
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
[rows, fournisseurOf]
);
const annees = useMemo(
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
[rows, anneeOf]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!filterFournisseur && !filterAnnee && !q) return rows;
return rows.filter(r =>
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
);
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
const numericKeys = useMemo(
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
[columns]
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
const numeric = numericKeys.has(sortKey);
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey];
const bv = (b as Record<string, unknown>)[sortKey];
if (numeric) {
const an = Number(av);
const bn = Number(bv);
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
if (Number.isNaN(an)) return 1;
if (Number.isNaN(bn)) return -1;
return (an - bn) * dir;
}
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
});
}, [filtered, sortKey, sortDir, numericKeys]);
// Toute modification des filtres / du tri ramène à la première page.
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
const [pagedSet, setPagedSet] = useState(sorted);
if (pagedSet !== sorted) {
setPagedSet(sorted);
setPage(1);
}
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[sorted, currentPage]
);
function handleSort(key: Extract<keyof T, string>) {
if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
const sorted = useMemo(() => [...rows].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
if (numericKeys.includes(sortKey as string)) {
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
const hasFilters = Boolean(filterFournisseur || filterAnnee || search);
async function handleExport() {
if (exporting) return;
setExporting(true);
try {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(excel.sheet);
ws.addRow(excel.header);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
for (const r of sorted) ws.addRow(excel.row(r));
ws.columns = excel.widths.map(width => ({ width }));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
const as = String(av ?? "").toLowerCase();
const bs = String(bv ?? "").toLowerCase();
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
}), [rows, sortKey, sortDir]);
return { sorted, sortKey, sortDir, handleSort };
}
// ---------------------------------------------------------------------------
// Onglet 1 — Stock Négatif
// ---------------------------------------------------------------------------
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
type SK = keyof PgStockNegatifRow;
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
const fournisseurs = useMemo(() =>
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
[rows]
);
const annees = useMemo(() =>
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
[rows]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(r =>
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
);
}, [rows, filterFournisseur, filterAnnee, search]);
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
filtered,
["stockdispo"]
);
const th = (key: SK, label: string) => (
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
async function handleExport() {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Stock Négatif");
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"]);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
for (const r of sorted) ws.addRow([r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
{exporting && (
<LoadingModal
message="Génération du fichier Excel"
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
@@ -151,8 +304,10 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
{(filterFournisseur || filterAnnee || search) && (
{anneeOf && (
<FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
)}
{hasFilters && (
<button
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
@@ -162,312 +317,170 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<span className="text-sm text-gray-500">
{total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
{hasFilters && total !== rows.length && (
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
)}
</span>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stockdispo", "Stock")}
{th("dernierevente", "Dernière vente")}
{th("derniereentree", "Dernière entrée")}
{columns.map(c => (
<th
key={c.key}
onClick={() => handleSort(c.key)}
className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
>
{c.label}
<SortIcon active={sortKey === c.key} dir={sortDir} />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
{pageRows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</td>
</tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
{pageRows.map((row, i) => (
<tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
className="hover:bg-gray-50 transition-colors">
{columns.map(c => (
<td key={c.key} className="px-3 py-2">
{c.render
? c.render(row)
: String((row as Record<string, unknown>)[c.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={currentPage}
totalPages={totalPages}
total={total}
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
to={Math.min(currentPage * PAGE_SIZE, total)}
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Onglet 2 — Entrées sans vente
// Cellules partagées
// ---------------------------------------------------------------------------
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
type SK = keyof PgStockSansVenteRow;
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
const fournisseurs = useMemo(() =>
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
[baseRows]
);
const annees = useMemo(() =>
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
[baseRows]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return baseRows.filter(r =>
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
);
}, [baseRows, filterFournisseur, filterAnnee, search]);
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
filtered,
["stock_actuel"]
);
const th = (key: SK, label: string) => (
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
async function handleExport() {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Entrées sans vente");
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"]);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)]);
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Code article ou libellé…"
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
{(filterFournisseur || filterAnnee || search) && (
<button
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
>
✕ Réinitialiser
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stock_actuel", "Stock actuel")}
{th("derniere_entree", "Dernière entrée")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
const cellSite = (v: string) => (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
);
const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
// ---------------------------------------------------------------------------
// Onglet 3 — Sans vente depuis 6 mois
// Configuration des 3 onglets
//
// Définies au niveau module : leur identité doit rester stable d'un rendu à
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
// ---------------------------------------------------------------------------
function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) {
type SK = keyof PgSansVente6MoisRow;
type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
const [filterFournisseur, setFilterFournisseur] = useState("");
const [search, setSearch] = useState("");
const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
fournisseurOf: r => r.fournisseur,
anneeOf: r => getYear(r.derniereentree),
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article en stock négatif",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
],
excel: {
sheet: "Stock Négatif",
filePrefix: "Stock_Negatif",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
widths: [16, 16, 10, 10, 16, 22],
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
},
};
const fournisseurs = useMemo(() =>
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
[rows]
);
const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
fournisseurOf: r => r.fournisseur,
anneeOf: r => getYear(r.derniere_entree),
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article trouvé",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
],
excel: {
sheet: "Entrées sans vente",
filePrefix: "Entrees_Sans_Vente",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
},
};
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(r =>
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
);
}, [rows, filterFournisseur, search]);
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgSansVente6MoisRow & Record<string, unknown>>(
filtered as (PgSansVente6MoisRow & Record<string, unknown>)[],
["stock_actuel", "jours_sans_vente"]
);
const th = (key: SK, label: string) => (
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
onClick={() => handleSort(key as string & keyof (PgSansVente6MoisRow & Record<string, unknown>))}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
async function handleExport() {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Sans vente 6 mois");
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"]);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)]);
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Sans_Vente_6Mois_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Code article ou libellé…"
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
{(filterFournisseur || search) && (
<button
onClick={() => { setFilterFournisseur(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
>
✕ Réinitialiser
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stock_actuel", "Stock actuel")}
{th("derniere_vente", "Dernière vente")}
{th("jours_sans_vente", "Jours sans vente")}
{th("derniere_entree", "Dernière entrée")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={8} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_vente)}</td>
<td className="px-3 py-2 text-right tabular-nums font-medium"
style={{ color: row.jours_sans_vente && row.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}>
{row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"}
</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
fournisseurOf: r => r.fournisseur,
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article trouvé",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
{ key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
{
key: "jours_sans_vente",
label: "Jours sans vente",
numeric: true,
render: r => (
<span
className="tabular-nums font-medium"
style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
>
{r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
</span>
),
},
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
],
excel: {
sheet: "Sans vente 6 mois",
filePrefix: "Sans_Vente_6Mois",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
},
};
// ---------------------------------------------------------------------------
// Composant principal avec onglets
@@ -481,6 +494,10 @@ const TABS = [
type TabKey = typeof TABS[number]["key"];
function isTabKey(v: string): v is TabKey {
return TABS.some(t => t.key === v);
}
interface Props {
rowsNegatif: PgStockNegatifRow[];
rowsSansVente: PgStockSansVenteRow[];
@@ -492,25 +509,59 @@ interface Props {
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) {
const router = useRouter();
const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey;
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
// Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés.
const [switching, startSwitch] = useTransition();
// Le changement de magasin, lui, relance les requêtes serveur.
const [navigating, startNavigation] = useTransition();
// Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct).
const [urlTab, setUrlTab] = useState(initialTab);
if (urlTab !== initialTab) {
setUrlTab(initialTab);
setActiveTab(initialTab);
}
function buildUrl(nextTab: TabKey, nextMagasin: string) {
const params = new URLSearchParams();
if (nextMagasin) params.set("magasin", nextMagasin);
params.set("tab", nextTab);
return `/stock-negatif?${params.toString()}`;
}
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
const val = e.target.value;
const params = new URLSearchParams();
if (val) params.set("magasin", val);
params.set("tab", activeTab);
router.push(`/stock-negatif?${params.toString()}`);
startNavigation(() => {
router.push(buildUrl(activeTab, val));
});
}
function handleTabChange(key: TabKey) {
const params = new URLSearchParams();
if (magasin) params.set("magasin", magasin);
params.set("tab", key);
router.push(`/stock-negatif?${params.toString()}`);
if (key === activeTab || switching) return;
// Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée.
window.history.replaceState(null, "", buildUrl(key, magasin));
startSwitch(() => setActiveTab(key));
}
const counts: Record<TabKey, number> = useMemo(() => ({
"negatif": rowsNegatif.length,
"sans-vente": rowsSansVente.length,
"sans-vente-6mois": rowsSansVente6Mois.length,
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
const busy = switching || navigating;
const showLoader = useDelayedFlag(busy);
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
const loaderSubMessage = navigating
? "Interrogation du stock pour le magasin sélectionné…"
: "Application des filtres et du tri…";
return (
<div className="space-y-4">
<div className="space-y-4" aria-busy={busy}>
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
@@ -518,7 +569,8 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
<select
value={magasin}
onChange={handleMagasinChange}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={navigating}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
>
<option value="">Tous les magasins</option>
{sites.map(s => (
@@ -531,37 +583,45 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-1">
{TABS.map(t => (
<button
key={t.key}
onClick={() => handleTabChange(t.key)}
className={[
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
activeTab === t.key
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
].join(" ")}
>
{t.label}
<span className={[
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
].join(" ")}>
{t.key === "negatif"
? rowsNegatif.length
: t.key === "sans-vente"
? rowsSansVente.filter(r => r.stock_actuel !== 0).length
: rowsSansVente6Mois.length}
</span>
</button>
))}
{TABS.map(t => {
const isActive = activeTab === t.key;
return (
<button
key={t.key}
onClick={() => handleTabChange(t.key)}
disabled={busy}
aria-current={isActive ? "page" : undefined}
className={[
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
isActive
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
busy ? "cursor-wait" : "",
].join(" ")}
>
{t.label}
<span className={[
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
].join(" ")}>
{counts[t.key].toLocaleString("fr-FR")}
</span>
</button>
);
})}
</nav>
</div>
{/* Tab content */}
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
{activeTab === "sans-vente-6mois" && <TabSansVente6Mois rows={rowsSansVente6Mois} magasin={magasin} />}
{activeTab === "negatif" && (
<StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
)}
{activeTab === "sans-vente" && (
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
)}
{activeTab === "sans-vente-6mois" && (
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
)}
</div>
);
}
-491
View File
@@ -1,491 +0,0 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import fs from "fs/promises";
import path from "path";
import { GoogleGenAI, Type } from "@google/genai";
interface AiConfig {
provider: "openrouter" | "google";
openRouterKey?: string;
googleAiKey?: string;
googleAiModel?: string;
}
async function getAiConfig(): Promise<AiConfig> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return {
provider: config.aiProvider ?? "openrouter",
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
};
} catch {
return {
provider: "openrouter",
openRouterKey: process.env.OPENROUTER_API_KEY,
};
}
}
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
// ─── Tool definitions ────────────────────────────────────────────────────────
const TOOLS_OPENROUTER = [
{
type: "function",
function: {
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: "object",
properties: {
endpoint: {
type: "string",
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["endpoint"],
},
},
},
];
const TOOLS_GOOGLE = [{
functionDeclarations: [
{
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: Type.OBJECT,
properties: {
endpoint: {
type: Type.STRING,
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["endpoint"],
},
},
],
}];
// ─── API caller ───────────────────────────────────────────────────────────────
async function callFfApi(endpoint: string): Promise<string> {
// Sécurité : uniquement GET, uniquement les endpoints documentés
const allowedPrefixes = [
"/api/health", "/api/sync", "/api/schema",
"/api/articles", "/api/stock", "/api/fournisseurs",
"/api/commandes", "/api/mouvements", "/api/performance",
"/api/publicites", "/api/commandes-auto",
];
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
if (!isAllowed) {
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
}
try {
const url = `${FF_API_BASE}${endpoint}`;
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
cache: "no-store",
});
if (!res.ok) {
return `Erreur API ${res.status}: ${await res.text()}`;
}
const data = await res.json();
const text = JSON.stringify(data);
// Tronquer si trop long (max 6000 chars)
if (text.length > 6000) {
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
}
return text;
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
return `Erreur réseau: ${msg}`;
}
}
// ─── System prompt ────────────────────────────────────────────────────────────
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
## Outil disponible
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
## API FF Nancy — Documentation complète
Base URL : ${FF_API_BASE}
Format des dates : YYYY-MM-DD
Identifiants articles : toujours \`no_id\` (entier interne)
Codes site : ex 000, 001, 002…
### Santé / Infra
- GET /api/health — vérifie que l'API et PostgreSQL répondent
- GET /api/sync/status — état de la synchronisation en cours
- GET /api/schema/tables?search=nom — liste les tables PG
- GET /api/schema/tables/:nom — colonnes d'une table
- GET /api/schema/top?limit=10 — tables les plus peuplées
### Articles
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
- GET /api/articles/:id — détail complet d'un article (no_id)
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
- GET /api/articles/:id/mouvements — historique des mouvements
- GET /api/articles/:id/mensuel — CA mensuel de l'article
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
### Stock
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
- GET /api/stock/site/:site — tous les articles d'un site donné
- GET /api/stock/article/:id/historique — historique stock de l'article
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
- GET /api/stock/valorisation — valeur totale du stock par site
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
### Fournisseurs
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
- GET /api/fournisseurs/:code/articles — articles du fournisseur
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
### Commandes
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
- GET /api/commandes/receptions/liste — liste des réceptions
### Commandes automatiques
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
### Mouvements
- GET /api/mouvements/types — types de mouvements disponibles
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
### Performance / CA
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
### Publicités
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
- GET /api/publicites/:code?site= — détail d'une publicité
## Contexte métier CollectFlow
- Application de gestion de gammes pour magasins Foirfouille Nancy
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
- Les ventes sont suivies par magasin (site) et par mois
- Les utilisateurs arbitrent les classifications gamme par fournisseur
## Stratégie d'analyse
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
## Format de réponse — IMPORTANT
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
- Réponds toujours en français
- Sois précis, structuré et complet — ne tronque jamais ta réponse
- Si les données sont nombreuses, résume et mets en avant les points clés`;
// ─── OpenRouter handler ───────────────────────────────────────────────────────
async function callOpenRouter(
messages: object[],
model: string,
apiKey: string,
useTools: boolean,
stream: boolean
): Promise<Response> {
const body: Record<string, unknown> = {
model,
messages,
temperature: 0.3,
max_tokens: 8192,
};
if (useTools) {
body.tools = TOOLS_OPENROUTER;
body.tool_choice = "auto";
}
if (stream) {
body.stream = true;
body.stream_options = { include_usage: true };
}
return fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow Admin Chat",
},
body: JSON.stringify(body),
});
}
// ─── Google AI handler ────────────────────────────────────────────────────────
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
if (!config.googleAiKey) {
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
}
const model = config.googleAiModel ?? "gemini-2.0-flash";
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function send(event: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
try {
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
const contents: Array<{ role: string; parts: any[] }> = [
...messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
];
for (let round = 0; round < 10; round++) {
const streamResponse = await ai.models.generateContentStream({
model,
contents: contents as any,
config: {
systemInstruction: SYSTEM_PROMPT,
tools: TOOLS_GOOGLE as any,
maxOutputTokens: 8192,
},
});
let collectedText = "";
const collectedParts: any[] = [];
for await (const chunk of streamResponse) {
const text = chunk.text;
if (text) {
collectedText += text;
send({ type: "text", content: text });
}
const parts = chunk.candidates?.[0]?.content?.parts;
if (parts) collectedParts.push(...parts);
}
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
if (fnCallParts.length === 0) break;
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
for (const part of fnCallParts) {
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
const args = fn.args ?? {};
send({ type: "tool_start", name: fn.name, args });
let result = "";
if (fn.name === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fn.name}`;
}
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
toolResultParts.push({ functionResponse: { name: fn.name, response: { output: result } } });
}
contents.push({ role: "user", parts: toolResultParts as any });
}
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
send({ type: "done" });
controller.close();
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
send({ type: "error", message: msg });
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// ─── POST handler ─────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || (session.user as any)?.role !== "admin") {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
}
const { messages, model } = await req.json();
const aiConfig = await getAiConfig();
if (aiConfig.provider === "google") {
return handleGoogleAi(messages, aiConfig);
}
const apiKey = aiConfig.openRouterKey;
if (!apiKey) {
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
}
const fullMessages: object[] = [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function send(event: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
try {
let currentMessages = [...fullMessages];
let totalPromptTokens = 0;
let totalCompletionTokens = 0;
let directAnswer: string | null = null;
for (let round = 0; round < 10; round++) {
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
if (!res.ok) {
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
controller.close();
return;
}
const data = await res.json();
if (data.usage) {
totalPromptTokens += data.usage.prompt_tokens ?? 0;
totalCompletionTokens += data.usage.completion_tokens ?? 0;
}
const assistantMessage = data.choices?.[0]?.message;
if (!assistantMessage) break;
currentMessages.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
directAnswer = assistantMessage.content ?? "";
break;
}
const toolResults: object[] = [];
for (const tc of toolCalls) {
const fnName = tc.function?.name;
let args: Record<string, unknown> = {};
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
send({ type: "tool_start", name: fnName, args });
let result = "";
if (fnName === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fnName}`;
}
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
}
currentMessages.push(...toolResults);
}
if (directAnswer !== null) {
send({ type: "text", content: directAnswer });
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
send({ type: "done" });
controller.close();
return;
}
// Final streaming call after tool use
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
if (!finalRes.ok) {
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
controller.close();
return;
}
const reader = finalRes.body?.getReader();
if (!reader) { controller.close(); return; }
const textDecoder = new TextDecoder();
let buffer = "";
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += textDecoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") continue;
try {
const chunk = JSON.parse(raw);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) send({ type: "text", content: delta });
if (chunk.usage) finalUsage = chunk.usage;
} catch { /* ignore */ }
}
}
if (finalUsage) {
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
}
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
send({ type: "done" });
controller.close();
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
send({ type: "error", message: msg });
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import fs from "fs/promises";
import path from "path";
export async function GET() {
const session = await auth();
if (!session || (session.user as any)?.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return NextResponse.json({
provider: config.aiProvider ?? "openrouter",
openRouterModel: config.openRouterModel,
googleAiModel: config.googleAiModel,
});
} catch {
return NextResponse.json({ provider: "openrouter" });
}
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { asc, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
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.
*
* Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a
* besoin de la vue d'ensemble pour décider qui cadencer.
*/
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const rows = await db
.select()
.from(syncFournisseurs)
.orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`));
return NextResponse.json({
success: true,
fournisseurs: rows,
resume: {
total: rows.length,
actifsSql: rows.filter((r) => r.actifSql).length,
actifsQlik: rows.filter((r) => r.actifQlik).length,
desactivesAuto: rows.filter((r) => r.desactiveMotif).length,
enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length,
enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length,
jamaisSql: rows.filter((r) => !r.dernierSqlAt).length,
},
});
}
const patchSchema = z.object({
/** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */
codes: z.array(z.string().min(1)).min(1).max(2000),
actifSql: z.boolean().optional(),
actifQlik: z.boolean().optional(),
}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, {
message: "Préciser au moins actifSql ou actifQlik",
});
/**
* PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
*
* Réactiver lève le motif de désactivation automatique : sans cela, un
* fournisseur remis en service par l'admin garderait un « aucun article »
* 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;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) },
{ status: 400 },
);
}
const { codes, actifSql, actifQlik } = parsed.data;
const reactivation = actifSql === true || actifQlik === true;
const rows = await db
.update(syncFournisseurs)
.set({
...(actifSql !== undefined ? { actifSql } : {}),
...(actifQlik !== undefined ? { actifQlik } : {}),
...(reactivation ? { desactiveMotif: null } : {}),
updatedAt: new Date(),
})
.where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes))
.returning({ code: syncFournisseurs.codeFournisseur });
console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`);
return NextResponse.json({ success: true, misAJour: rows.length });
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
import {
getSyncSchedulerState,
lancerRound,
demanderArret,
debutFenetre,
seedSyncFournisseurs,
} from "@/features/admin/api/sync-scheduler";
export const runtime = "nodejs";
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 settings = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(settings, maintenant);
return NextResponse.json({
success: true,
settings,
state: getSyncSchedulerState(),
/** true = on est actuellement dans la fenêtre nocturne. */
dansLaFenetre: debut !== null,
fenetreDebutAt: debut?.toISOString() ?? null,
maintenant: maintenant.toISOString(),
});
}
const settingsSchema = z.object({
actif: z.boolean().optional(),
heureDebut: z.coerce.number().int().min(0).max(23).optional(),
heureFin: z.coerce.number().int().min(0).max(23).optional(),
qlikParNuit: z.coerce.number().int().min(0).max(200).optional(),
sqlParQlik: z.coerce.number().int().min(1).max(500).optional(),
qlikMinJours: z.coerce.number().int().min(0).max(365).optional(),
});
/** PATCH — met à jour les réglages. */
export async function PATCH(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = settingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
{ status: 400 },
);
}
const settings = await saveSyncSettings(parsed.data);
return NextResponse.json({ success: true, settings });
}
const actionSchema = z.object({
action: z.enum(["start", "stop", "seed"]),
/**
* Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait
* ce qu'il fait : en pleine journée, la charge est visible par les magasins.
*/
forcer: z.boolean().optional(),
});
/** 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;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 });
}
if (parsed.data.action === "seed") {
const n = await seedSyncFournisseurs();
return NextResponse.json({ success: true, fournisseurs: n });
}
if (parsed.data.action === "stop") {
demanderArret();
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
const etat = getSyncSchedulerState();
if (etat.enCours) {
return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 });
}
// Fire-and-forget : le round dure des minutes, le client suit via GET.
void lancerRound("manuel", parsed.data.forcer === true).catch((e) => {
console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e));
});
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
export const dynamic = "force-dynamic";
/**
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
* la page Gestion de Stock.
*
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
*/
export async function GET() {
try {
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedGoogleKey(): Promise<string | null> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return config.googleAiKey ?? null;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-google-ai-key")
|| process.env.GOOGLE_AI_KEY
|| await getSavedGoogleKey();
if (!apiKey) {
return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 });
}
try {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`,
{ cache: "no-store" }
);
if (!res.ok) {
const err = await res.text();
return NextResponse.json({ error: err }, { status: res.status });
}
const data = await res.json();
// Keep only generative models that support generateContent
const models = (data.models as Array<{
name: string;
displayName: string;
supportedGenerationMethods?: string[];
description?: string;
}>)
.filter(m =>
m.supportedGenerationMethods?.includes("generateContent") &&
!m.name.includes("embedding") &&
!m.name.includes("aqa")
)
.map(m => ({
id: m.name.replace("models/", ""),
name: m.displayName,
}))
.sort((a, b) => a.name.localeCompare(b.name, "fr"));
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+2 -1
View File
@@ -18,10 +18,11 @@ export async function GET(request: NextRequest) {
const magasin = searchParams.get("magasin") ?? "TOTAL";
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
const filters: Partial<GridFilters> = {
code1: searchParams.get("code1"),
code2: searchParams.get("code2"),
code3: searchParams.get("code3"),
};
const forceRefresh = searchParams.get("refresh") === "1";
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedOpenRouterKey(): Promise<string | null> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return config.openRouterKey ?? null;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-openrouter-key")
|| process.env.OPENROUTER_API_KEY
|| await getSavedOpenRouterKey();
if (!apiKey) {
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
}
try {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
}
const data = await res.json();
// Return simplified list sorted by name
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
.map((m) => ({
id: m.id,
name: m.name,
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
}))
.sort((a, b) => {
// Free models first, then alphabetical
if (a.free && !b.free) return -1;
if (!a.free && b.free) return 1;
return a.name.localeCompare(b.name, "fr");
});
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+117 -5
View File
@@ -77,26 +77,36 @@ export async function GET(req: NextRequest) {
computedOnDemand = true;
}
const result = await queryGridRows({
const filtres = {
codeFournisseur: q.fournisseur,
gamme: q.gamme,
code1: q.code1,
code2: q.code2,
code3: q.code3,
nomenclature: q.nomenclature,
search: q.search,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait
// exploser la mémoire du processus : on diffuse par lots, à mémoire constante.
if (q.limit == null) {
return streamAllRows(req, filtres, q, freshness, computedOnDemand);
}
const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit });
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
const pagination = buildPagination(q.page, q.limit, result.total);
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
pagination,
meta: {
fournisseur: q.fournisseur,
computedAt: result.computedAt,
@@ -105,7 +115,109 @@ export async function GET(req: NextRequest) {
enrichi: q.enrich === "1",
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
computedOnDemand,
/**
* true = cette réponse contient **toutes** les lignes du fournisseur.
* Dit explicitement à un appelant — en particulier un agent — s'il
* peut s'arrêter là, au lieu de le laisser déduire d'une pagination
* qu'il risque d'ignorer.
*/
complet: !pagination.hasMore,
...(pagination.hasMore
? {
avertissement:
`Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. `
+ `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`,
}
: {}),
},
},
);
}
/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */
const STREAM_BATCH = 1000;
type Filtres = Parameters<typeof queryGridRows>[0];
/**
* Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
* jamais la construire entièrement en mémoire.
*
* La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un
* client existant ne voit aucune différence, il reçoit simplement les octets au
* fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total
* connu — l'ordre des clés n'a aucune importance en JSON.
*
* Une erreur survenant après les premiers octets ne peut plus changer le code
* HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant
* détecte une réponse tronquée au lieu de la croire complète.
*/
function streamAllRows(
req: NextRequest,
filtres: Filtres,
q: { enrich: string; fields?: string; fournisseur: string },
freshness: { rowCount: number; computedAt: string | null },
computedOnDemand: boolean,
): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const write = (s: string) => controller.enqueue(encoder.encode(s));
let envoyees = 0;
let total = 0;
let computedAt: string | null = null;
let erreur: string | null = null;
write('{"data":[');
try {
for (let page = 1; ; page++) {
const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH });
if (page === 1) {
total = lot.total;
computedAt = lot.computedAt;
}
if (lot.rows.length === 0) break;
const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows;
for (const r of enrichies) {
write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields)));
envoyees++;
}
if (lot.rows.length < STREAM_BATCH) break;
}
} catch (e) {
erreur = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur);
}
const complet = erreur === null && envoyees === total;
write("]," + JSON.stringify({
pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet },
meta: {
fournisseur: q.fournisseur,
computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
computedOnDemand,
diffuse: true,
complet,
...(erreur
? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` }
: {}),
},
}).slice(1));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
"X-Accel-Buffering": "no",
},
});
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
*
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
* nombre d'articles et le CA de chacun.
*
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
* 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
* pour une application. On liste d'abord les postes — quelques dizaines de
* lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
*
* Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
* niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
* un préfixe pour descendre l'arborescence sans tout charger.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { fournisseur, niveau, parent } = parsed.data;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
const freshness = await getGridFreshness(fournisseur);
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
{ fournisseur },
);
}
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
return ok(postes, {
meta: {
fournisseur,
niveau,
parent: parent ?? null,
nbPostes: postes.length,
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
nbArticles,
snapshotRowCount: freshness.rowCount,
snapshotComputedAt: freshness.computedAt,
/**
* Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
* `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
*/
filtreGrid: "nomenclature",
},
});
}
+51 -2
View File
@@ -22,7 +22,7 @@ export async function GET(req: NextRequest) {
const paginationParams = [
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, description: "Nombre de lignes par page (500 maximum)." },
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." },
];
const sortParams = [
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
@@ -93,6 +93,7 @@ export async function GET(req: NextRequest) {
limit: { type: "integer" },
total: { type: "integer" },
totalPages: { type: "integer" },
hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." },
},
},
Fournisseur: {
@@ -206,6 +207,53 @@ export async function GET(req: NextRequest) {
},
},
},
"/nomenclatures": {
get: {
operationId: "listerNomenclaturesFournisseur",
summary: "Postes de nomenclature d'un fournisseur",
description:
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
+ "(code1, code2 ou code3).",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
{ name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." },
{ name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." },
],
responses: {
"200": {
description: "Postes de nomenclature",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
libelle: { type: ["string", "null"] },
nbArticles: { type: "integer" },
totalCa: { type: "number" },
totalQuantite: { type: "number" },
},
},
},
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/grid": {
get: {
operationId: "listerProduitsFournisseur",
@@ -221,7 +269,8 @@ export async function GET(req: NextRequest) {
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
{ name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." },
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
{
name: "compute",
+41
View File
@@ -238,6 +238,47 @@
background: rgba(0, 0, 0, 0.18);
}
/* ── Défilement de la Grille ──
La règle ci-dessus (8 px, rail transparent, pouce très pâle) convient aux
panneaux où l'ascenseur est accessoire. Dans la Grille, le tableau fait
~2600 px de large : l'ascenseur HORIZONTAL est l'outil de navigation
principal, et un rail invisible ne dit pas qu'il reste des colonnes à
droite. On lui donne donc un rail dessiné et un pouce saisissable.
`scrollbar-*` couvre Firefox, que les sélecteurs -webkit- ignorent. */
.grid-scroll {
scrollbar-width: auto;
scrollbar-color: var(--text-muted) var(--bg-elevated);
}
.grid-scroll::-webkit-scrollbar {
width: 10px;
height: 14px;
}
.grid-scroll::-webkit-scrollbar-track:horizontal {
background: var(--bg-elevated);
border-top: 1px solid var(--border);
}
.grid-scroll::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 99px;
/* Bordure transparente + background-clip : le pouce garde de l'air dans son
rail sans qu'on ait à jouer sur des marges, impossibles ici. */
border: 3px solid transparent;
background-clip: padding-box;
}
.grid-scroll::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
background-clip: padding-box;
}
/* Le coin entre les deux ascenseurs, sinon laissé en blanc par le navigateur. */
.grid-scroll::-webkit-scrollbar-corner {
background: var(--bg-elevated);
}
/* ── Heatmap cells ── */
.heatmap-zero {
background: var(--bg-elevated);
+2 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@@ -19,7 +19,7 @@ const NAV_ITEMS = [
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
{ icon: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", adminOnly: true },
{ icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true },
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
];
+50
View File
@@ -220,6 +220,56 @@ export const apiKeys = pgTable("api_keys", {
revokedAt: timestamp("revoked_at"),
});
/**
* Paramétrage et suivi de la synchronisation nocturne, un enregistrement par
* fournisseur.
*
* Deux sources aux coûts très différents, donc deux cadences distinctes :
* l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit —
* et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et
* `actifQlik` séparés, et deux dates de dernier passage.
*
* L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la
* nuit suivante reprend donc naturellement là où la précédente s'est arrêtée,
* et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun
* curseur ni compteur de tour à maintenir.
*/
export const syncFournisseurs = pgTable("sync_fournisseurs", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Inclus dans la rotation de l'instantané SQL. */
actifSql: boolean("actif_sql").default(true).notNull(),
/** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */
actifQlik: boolean("actif_qlik").default(true).notNull(),
/**
* Renseigné quand le planificateur a désactivé le fournisseur de lui-même —
* typiquement « aucun article ». Évite de réessayer chaque nuit un
* fournisseur vide, tout en gardant la raison visible pour le réactiver.
*/
desactiveMotif: text("desactive_motif"),
dernierSqlAt: timestamp("dernier_sql_at"),
/** 'succes' | 'echec' | 'vide' */
dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }),
dernierSqlLignes: integer("dernier_sql_lignes"),
dernierSqlErreur: text("dernier_sql_erreur"),
dernierSqlMs: integer("dernier_sql_ms"),
dernierQlikAt: timestamp("dernier_qlik_at"),
dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }),
dernierQlikCodes: integer("dernier_qlik_codes"),
dernierQlikErreur: text("dernier_qlik_erreur"),
dernierQlikMs: integer("dernier_qlik_ms"),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
// Index de file d'attente : « le plus ancien d'abord », par source.
index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt),
index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt),
];
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
+366
View File
@@ -0,0 +1,366 @@
/**
* CollectFlow — Synchronisation nocturne des fournisseurs.
*
* Deux sources aux coûts très différents, donc deux files distinctes :
* • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros
* fournisseur du catalogue. Alimente la Grille et `/api/v1`.
* • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
* serveur externe qui abandonne quand on le sollicite trop. Ce sont des
* agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien.
*
* ORDRE DE PASSAGE — le point de conception central. La file est simplement
* « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture
* de la fenêtre courante. Il en découle, sans curseur ni compteur de tour :
* • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les
* plus anciens) ;
* • quand plus personne n'est en attente, le tour est fini ; la nuit suivante
* tout le monde redevient éligible et le tour recommence ;
* • un fournisseur en échec garde une date ancienne, donc il repasse en tête —
* le rattrapage est automatique.
*
* La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
* « ne plus en **démarrer** après l'heure de fin ».
*/
import "server-only";
import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
export type { SyncSettings };
// ─── État courant, en mémoire ────────────────────────────────────────────────
export interface SyncSchedulerState {
enCours: boolean;
/** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */
origine: "auto" | "manuel" | null;
debutAt: string | null;
finAt: string | null;
fournisseurCourant: string | null;
etape: "sql" | "qlik" | null;
sqlFaits: number;
sqlEchecs: number;
qlikFaits: number;
qlikEchecs: number;
/** Renseigné quand la file s'est vidée : le tour est bouclé. */
tourTermine: boolean;
arretDemande: boolean;
derniereErreur: string | null;
}
const etat: SyncSchedulerState = {
enCours: false, origine: null, debutAt: null, finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
};
export function getSyncSchedulerState(): SyncSchedulerState {
return { ...etat };
}
export function demanderArret(): void {
if (etat.enCours) etat.arretDemande = true;
}
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
/**
* Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors.
*
* Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure
* de fin, la fenêtre a commencé **la veille**.
*/
export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null {
const h = maintenant.getHours();
const debutDuJour = new Date(maintenant);
debutDuJour.setHours(s.heureDebut, 0, 0, 0);
if (s.heureDebut < s.heureFin) {
return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null;
}
// Fenêtre à cheval sur minuit.
if (h >= s.heureDebut) return debutDuJour;
if (h < s.heureFin) {
const veille = new Date(debutDuJour);
veille.setDate(veille.getDate() - 1);
return veille;
}
return null;
}
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
/**
* Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
* fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien —
* l'historique de synchronisation d'un fournisseur disparu reste consultable.
*/
export async function seedSyncFournisseurs(): Promise<number> {
const fournisseurs = await pgGetFournisseurs();
if (fournisseurs.length === 0) return 0;
const CHUNK = 500;
for (let i = 0; i < fournisseurs.length; i += CHUNK) {
const lot = fournisseurs.slice(i, i + CHUNK);
await db
.insert(syncFournisseurs)
.values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom })))
.onConflictDoUpdate({
target: syncFournisseurs.codeFournisseur,
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
set: { nomFournisseur: sql`excluded.nom_fournisseur` },
});
}
return fournisseurs.length;
}
// ─── Files d'attente ─────────────────────────────────────────────────────────
/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */
async function prochainSql(depuis: Date) {
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifSql, true),
or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`))
.limit(1);
return row ?? null;
}
/**
* Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`.
* Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque
* nuit une extraction de plusieurs minutes pour des agrégats mensuels.
*/
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifQlik, true),
or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
.limit(1);
return row ?? null;
}
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
/**
* Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans
* cela, un fournisseur qui échoue resterait éternellement le plus ancien et
* bloquerait la file derrière lui.
*/
async function traiterSql(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "sql";
try {
const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true });
const vide = rows.length === 0;
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: vide ? "vide" : "succes",
dernierSqlLignes: rows.length,
dernierSqlErreur: null,
dernierSqlMs: Date.now() - t0,
// Un fournisseur sans article n'a rien à donner : on le sort des deux
// files plutôt que de le réessayer chaque nuit. L'admin voit le motif
// et peut le réactiver.
...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}),
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`);
else etat.sqlFaits++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.sqlEchecs++;
etat.derniereErreur = `${code} (SQL) : ${msg}`;
console.error(`[sync-nuit] SQL ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: "echec",
dernierSqlErreur: msg.slice(0, 500),
dernierSqlMs: Date.now() - t0,
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ });
}
}
/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */
const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */
async function traiterQlik(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "qlik";
try {
const articles = await pgGetArticlesByFournisseur(code);
const codes = [...new Set(
articles
.map((a) => String(a.codeCentrale ?? "").trim())
.filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)),
)];
if (codes.length === 0) {
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
return;
}
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code);
const n = await upsertNetworkMetrics([...metrics.values()]);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
etat.qlikFaits++;
console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.qlikEchecs++;
etat.derniereErreur = `${code} (Qlik) : ${msg}`;
console.error(`[sync-nuit] Qlik ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "echec",
dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ });
}
}
// ─── Boucle de travail ───────────────────────────────────────────────────────
/**
* Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
*
* Les deux files avancent en parallèle : une extraction Qlik est intercalée
* tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
* démarrerait jamais tant que la file SQL n'est pas vide.
*/
export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise<void> {
if (etat.enCours) return;
const s = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(s, maintenant);
if (!forcerHorsFenetre && !debut) return;
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0));
Object.assign(etat, {
enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
});
console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`);
try {
await seedSyncFournisseurs();
// Garde anti-boucle. L'avancement de la file repose sur l'écriture de
// `dernier*At`, et cette écriture est volontairement non bloquante en cas
// d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et
// serait repris sans fin : on retient donc ce qui a déjà été tenté.
const tentes = new Set<string>();
let depuisDernierQlik = 0;
for (;;) {
if (etat.arretDemande) break;
if (!forcerHorsFenetre && !debutFenetre(s, new Date())) {
console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
break;
}
const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit;
// Qlik prioritaire quand le quota d'entrelacement est atteint.
if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
const cibleSql = await prochainSql(depuis);
if (cibleSql) {
if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) {
console.error(
`[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :`
+ " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
);
break;
}
tentes.add(`sql:${cibleSql.codeFournisseur}`);
await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur);
depuisDernierQlik++;
continue;
}
// File SQL vide : on consacre le temps restant à Qlik.
if (qlikPossible) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
etat.tourTermine = true;
console.log("[sync-nuit] plus rien en attente — tour terminé");
break;
}
} catch (e) {
etat.derniereErreur = e instanceof Error ? e.message : String(e);
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
} finally {
etat.enCours = false;
etat.fournisseurCourant = null;
etat.etape = null;
etat.finAt = new Date().toISOString();
console.log(
`[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,`
+ ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`,
);
}
}
/**
* Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
* deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé
* chaque minute.
*/
export async function tickScheduler(): Promise<void> {
try {
const s = await readSyncSettings();
if (!s.actif || etat.enCours) return;
if (!debutFenetre(s)) return;
await lancerRound("auto");
} catch (e) {
console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e));
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* CollectFlow — Réglages de la synchronisation nocturne.
*
* Rangés dans `data/.db-config.json`, comme les autres réglages de
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
* sauvegarder et à monter dans le conteneur.
*
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
* (les défauts, le type), ce que `"use server"` interdit.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface SyncSettings {
/**
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
*/
actif: boolean;
/** Heure d'ouverture de la fenêtre (0-23). */
heureDebut: number;
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
heureFin: number;
/**
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
* principale protection contre une nuit entièrement passée sur Qlik.
*/
qlikParNuit: number;
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
sqlParQlik: number;
/**
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
*/
qlikMinJours: number;
}
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
actif: false,
heureDebut: 0,
heureFin: 5,
qlikParNuit: 6,
sqlParQlik: 20,
qlikMinJours: 7,
};
function borne(v: unknown, min: number, max: number, defaut: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return defaut;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** Normalise ce qui vient du fichier : il est éditable à la main. */
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
const d = SYNC_SETTINGS_DEFAUT;
const s: SyncSettings = {
actif: Boolean(brut?.actif),
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
// plutôt que de laisser le planificateur muet sans explication.
if (s.heureDebut === s.heureFin) {
s.heureDebut = d.heureDebut;
s.heureFin = d.heureFin;
}
return s;
}
export async function readSyncSettings(): Promise<SyncSettings> {
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
return normaliser(cfg?.syncNuit);
} catch {
return { ...SYNC_SETTINGS_DEFAUT };
}
}
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
let existant: Record<string, unknown> = {};
try {
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
} catch {
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
}
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
const suivant = normaliser({ ...courant, ...patch });
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
return suivant;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* CollectFlow — Minuteur de la synchronisation nocturne.
*
* Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute
* et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors
* fenêtre, ou si un round tourne déjà, le battement ne coûte rien.
*
* Conséquence assumée du choix « minuteur interne » plutôt que cron système :
* rien ne tourne si l'application est arrêtée. En contrepartie, aucune
* configuration côté hôte, et le minuteur se réarme tout seul au redémarrage.
*/
import "server-only";
import { tickScheduler } from "@/features/admin/api/sync-scheduler";
const INTERVALLE_MS = 60_000;
/**
* Garde au niveau du module : en développement, le rechargement à chaud peut
* réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de
* minuteurs empilés.
*/
let minuteur: ReturnType<typeof setInterval> | null = null;
export function startSyncTimer(): void {
if (minuteur) return;
console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`);
minuteur = setInterval(() => {
// `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre
// qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non
// gérée capable d'abattre le processus.
void tickScheduler().catch((e) => {
console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e));
});
}, INTERVALLE_MS);
// Ne pas maintenir le processus en vie uniquement pour ce minuteur.
minuteur.unref?.();
}
@@ -43,6 +43,7 @@ function CodeLine({ children, copy }: { children: string; copy?: string }) {
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
@@ -50,10 +51,11 @@ const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
];
const PARAMS: Array<{ name: string; desc: string }> = [
{ name: "page, limit", desc: "Pagination (limit ≤ 500, défaut 100)" },
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" },
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et la nomenclature" },
{ name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" },
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" },
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
@@ -122,6 +124,17 @@ export function ApiConnectionInfo() {
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Tout un fournisseur en un seul appel — il suffit d&apos;omettre <code className="font-mono">limit</code>,
et <code className="font-mono">meta.complet</code> confirme qu&apos;il ne reste rien à lire :
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Gros fournisseur (plusieurs dizaines de milliers d&apos;articles) : lister d&apos;abord les
postes de nomenclature, puis les traiter un par un.
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
+75 -4
View File
@@ -9,6 +9,7 @@ import {
pgGetNomenclatureByFournisseur,
pgGetStockByFournisseur,
pgGetCommandesByFournisseur,
pgGetPrixVenteByFournisseur,
type PgStockRow,
} from "@/lib/pg-ff-client";
import { db } from "@/db";
@@ -124,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
// ─── Phase 1 : 6 requêtes SQL en parallèle ────────────────────────────
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Remplace des centaines/milliers d'appels HTTP per-article.
const [
articles,
@@ -133,6 +134,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
nomMap,
stockMap,
commandesMap,
prixVenteMap,
] = await Promise.all([
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
@@ -140,6 +142,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
]);
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
@@ -288,6 +291,25 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
product.caByStore[site] = sCa;
product.margeByStore[site] = sMarge;
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Sur un produit à faible rotation,
// « tous magasins » n'affichait donc que le site actif, en pratique
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
// l'autre : leur somme est la bonne.
const sites = Object.keys(product.stock12mByStore);
if (sites.length > 0) {
for (const periode of sortedPeriods) {
product.stock12m[periode] = sites.reduce(
(total, site) => total + (product.stock12mByStore![site][periode] ?? 0),
0,
);
}
}
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
@@ -355,6 +377,35 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
}
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
// borne de paramétrage : elle est vide en base et laissait la colonne PV
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
// site. On garde le détail par magasin — deux magasins peuvent ne pas
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
for (const product of productMap.values()) {
const parSite = prixVenteMap.get(product.codein);
if (parSite && Object.keys(parSite).length > 0) {
product.prixVenteByStore = parSite;
} else {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
const depuisStock: Record<string, number> = {};
for (const s of stockMap.get(product.codein) ?? []) {
const pv = Number(s.pv);
const site = String(s.site ?? "").trim();
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
}
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
}
const prix = Object.values(product.prixVenteByStore ?? {});
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if (prix.length > 0) product.prixVente = Math.max(...prix);
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
await enrichWithNetworkMetrics(productMap);
@@ -396,7 +447,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
} catch (error) {
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
return [];
// Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur
// sans article. La Grille affichait une page blanche sans explication, et
// la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle
// désactivait alors automatiquement. La remonter laisse chaque appelant la
// traiter comme une erreur (bandeau rouge, statut « echec »).
throw error instanceof Error
? error
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
}
}
@@ -417,9 +475,22 @@ async function reconcileSelectedStoreFromMensuelApi(
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 retenus = candidates.slice(0, PLAFOND);
if (candidates.length > retenus.length) {
console.warn(
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
);
}
try {
const mensuelMap = await getMensuelByArticles(
candidates.map((row) => ({
retenus.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
@@ -431,7 +502,7 @@ async function reconcileSelectedStoreFromMensuelApi(
);
let fixedRows = 0;
for (const row of candidates) {
for (const row of retenus) {
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
@@ -0,0 +1,458 @@
"use client";
/**
* CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas.
*
* On part du CLASSEMENT AFFICHÉ, pas d'un classement maison : le tri en cours
* dans la Grille — CA réseau, quantité, marge, peu importe — fixe l'ordre et
* donc ce que « le haut du classement » veut dire. Recalculer un ordre ici
* répondrait à une autre question que celle qu'on vient de poser à l'écran.
*
* « Non travaillé » = aucune vente sur 12 mois ET aucun stock au dernier mois
* connu. Les deux conditions comptent : un article sans vente mais en stock est
* détenu par le magasin, ce n'est pas un trou d'assortiment mais un invendu.
*/
import React, { useMemo, useState } from "react";
import { PackageX, Store, FileSpreadsheet, Loader2 } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { SITE_LABELS } from "@/features/grid/lib/months";
import type { ProductRow } from "@/types/grid";
/** Ligne du classement affiché : le produit et la valeur qui l'a classé. */
export interface LigneClassee {
row: ProductRow;
valeurCritere: unknown;
}
const PROFONDEURS = [100, 200, 500] as const;
export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1];
const nb = (v: number) => Math.round(v).toLocaleString("fr-FR");
const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`;
const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
/**
* Libellé et mise en forme de la colonne de tri.
*
* Le tri porte sur des grandeurs sans commune mesure — des euros, des unités,
* des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et
* ne rien afficher priverait le classement de sa justification.
*/
interface Critere {
label: string;
format: (v: number) => string;
/** Format de nombre Excel — la valeur part BRUTE dans le tableur, qui doit
* pouvoir la trier et la sommer ; seule sa présentation est reprise ici. */
xlsx: string;
/** Normalisation avant export, quand la donnée stockée n'est pas l'unité affichée. */
brut?: (v: number) => number;
}
const CRITERES: Record<string, Critere> = {
caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' },
qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" },
nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" },
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" },
prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' },
prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' },
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`,
xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) },
totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" },
totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' },
};
function libelleCritere(id: string | null): string {
if (!id) return "ordre d'affichage";
return CRITERES[id]?.label ?? id;
}
function formatCritere(id: string | null, valeur: unknown): string {
if (valeur == null) return "—";
if (typeof valeur !== "number") return String(valeur);
// Les sentinelles de tri (valeurs manquantes, produit « nouveau ») ne sont
// pas des mesures : les écrire donnerait un nombre qui n'existe pas.
if (!Number.isFinite(valeur)) return "—";
return (id && CRITERES[id]?.format(valeur)) ?? nb(valeur);
}
/** Ce que le magasin fait de ce produit, et ce que les autres en font. */
interface Constat {
ventesMagasin: number;
stockMagasin: number;
/** Le magasin a déjà détenu ce produit sur la période (stock ou réception). */
dejaDetenu: boolean;
ventesAilleurs: number;
stockAilleurs: number;
}
function constater(row: ProductRow, site: string, mois: string[]): Constat {
const ventesParSite = row.sales12mByStore ?? {};
const stockParSite = row.stock12mByStore ?? {};
const receptionsParSite = row.receptions12mByStore ?? {};
const somme = (serie?: Record<string, number>) =>
mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0);
const dernier = (serie?: Record<string, number>) => serie?.[mois[mois.length - 1]] ?? 0;
const ventesMagasin = row.quantiteByStore?.[site] ?? somme(ventesParSite[site]);
const stockMagasin = dernier(stockParSite[site]);
const maxStock = mois.reduce((max, m) => Math.max(max, stockParSite[site]?.[m] ?? 0), 0);
let ventesAilleurs = 0;
let stockAilleurs = 0;
for (const autre of new Set([...Object.keys(ventesParSite), ...Object.keys(stockParSite)])) {
if (autre === site) continue;
ventesAilleurs += row.quantiteByStore?.[autre] ?? somme(ventesParSite[autre]);
stockAilleurs += dernier(stockParSite[autre]);
}
return {
ventesMagasin,
stockMagasin,
dejaDetenu: maxStock > 0 || somme(receptionsParSite[site]) > 0,
ventesAilleurs,
stockAilleurs,
};
}
/** Ligne prête pour le tableur : le rang, le produit, le constat. */
interface TrouClasse {
row: ProductRow;
valeurCritere: unknown;
rang: number;
constat: Constat;
}
/**
* Classeur Excel de la liste affichée.
*
* Deux feuilles : les données seules d'un côté — en-têtes ligne 1, filtre
* automatique, valeurs NUMÉRIQUES pour que le tableur puisse trier et sommer —
* et le contexte de l'extraction de l'autre. Mêler les deux dans une même
* feuille condamnerait le filtre et le tri, alors que ce fichier a vocation à
* être retravaillé.
*
* `exceljs` est chargé à la demande : il pèse lourd, et la Grille n'a pas à le
* transporter pour tous ceux qui n'exportent jamais.
*/
async function construireClasseur(
trous: TrouClasse[],
contexte: { fournisseur?: string; magasin: string; critere: string; profondeur: number; examines: number },
critereId: string | null,
): Promise<Blob> {
const { Workbook } = await import("exceljs");
const classeur = new Workbook();
classeur.creator = "CollectFlow";
classeur.created = new Date();
const critere = critereId ? CRITERES[critereId] : undefined;
const feuille = classeur.addWorksheet("Non travaillés", { views: [{ state: "frozen", ySplit: 1 }] });
feuille.columns = [
{ header: "Rang", key: "rang", width: 8 },
{ header: "Code interne", key: "codein", width: 14 },
{ header: "Référence", key: "reference", width: 18 },
{ header: "EAN / GTIN", key: "gtin", width: 16 },
{ header: "Désignation", key: "libelle", width: 45 },
{ header: "Famille", key: "famille", width: 30 },
{ header: contexte.critere, key: "critere", width: 18 },
{ header: "Ventes ailleurs 12 m", key: "ventesAilleurs", width: 20 },
{ header: "Stock ailleurs", key: "stockAilleurs", width: 15 },
{ header: "Constat", key: "constat", width: 24 },
];
feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } };
feuille.getRow(1).height = 20;
for (const { row, valeurCritere, rang, constat } of trous) {
feuille.addRow({
rang,
codein: row.codein,
reference: row.reference || "",
gtin: row.gtin || "",
libelle: row.libelle1 || "",
famille: [row.code3, row.libelle3].filter(Boolean).join(" — "),
critere: typeof valeurCritere === "number" && Number.isFinite(valeurCritere)
? (critere?.brut?.(valeurCritere) ?? valeurCritere)
: (valeurCritere == null ? "" : String(valeurCritere)),
ventesAilleurs: constat.ventesAilleurs,
stockAilleurs: constat.stockAilleurs,
constat: constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu",
});
}
if (critere) feuille.getColumn("critere").numFmt = critere.xlsx;
feuille.getColumn("ventesAilleurs").numFmt = "#,##0";
feuille.getColumn("stockAilleurs").numFmt = "#,##0";
feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } };
const contexteFeuille = classeur.addWorksheet("Contexte");
contexteFeuille.columns = [{ width: 26 }, { width: 60 }];
for (const [cle, valeur] of [
["Extraction", "Produits non travaillés par un magasin"],
["Fournisseur", contexte.fournisseur ?? "—"],
["Magasin examiné", contexte.magasin],
["Classement", `${contexte.critere} — ${contexte.profondeur} premiers (${contexte.examines} examinés)`],
["Critère retenu", "aucune vente sur 12 mois glissants ET aucun stock au dernier mois connu"],
["Non travaillés", String(trous.length)],
["Généré le", new Date().toLocaleString("fr-FR")],
]) {
const ligne = contexteFeuille.addRow([cle, valeur]);
ligne.getCell(1).font = { bold: true };
}
const buffer = await classeur.xlsx.writeBuffer();
return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
}
export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, nomFournisseur, onClose }: {
/** Le classement AFFICHÉ, dans son ordre — déjà tronqué à PROFONDEUR_MAX. */
classement: LigneClassee[];
critereId: string | null;
/** Les 12 clés de mois, du plus ancien au plus récent. */
mois: string[];
magasinInitial: string;
/** Sert à nommer le fichier exporté et à le documenter. */
nomFournisseur?: string;
onClose: () => void;
}) {
// Magasins réellement présents dans les données, à défaut les deux sites connus.
const sites = useMemo(() => {
const trouves = new Set<string>();
for (const { row } of classement) {
for (const s of Object.keys(row.sales12mByStore ?? {})) trouves.add(s);
for (const s of Object.keys(row.stock12mByStore ?? {})) trouves.add(s);
}
return trouves.size > 0 ? [...trouves].sort() : Object.keys(SITE_LABELS);
}, [classement]);
const [site, setSite] = useState(() => (sites.includes(magasinInitial) ? magasinInitial : sites[0]));
const [profondeur, setProfondeur] = useState<number>(200);
const examine = useMemo(() => classement.slice(0, profondeur), [classement, profondeur]);
const trous = useMemo(() => examine
.map((ligne, index) => ({ ...ligne, rang: index + 1, constat: constater(ligne.row, site, mois) }))
.filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0),
[examine, site, mois]);
const [exportEnCours, setExportEnCours] = useState(false);
const [exportErreur, setExportErreur] = useState<string | null>(null);
const vendusAilleurs = trous.filter((t) => t.constat.ventesAilleurs > 0).length;
const dejaTentes = trous.filter((t) => t.constat.dejaDetenu).length;
const nomMagasin = SITE_LABELS[site]?.nom ?? site;
const autresMagasins = sites.filter((s) => s !== site).map((s) => SITE_LABELS[s]?.nom ?? s).join(", ") || "les autres";
const exporter = async () => {
if (trous.length === 0 || exportEnCours) return;
setExportEnCours(true);
setExportErreur(null);
try {
const blob = await construireClasseur(
trous,
{
fournisseur: nomFournisseur,
magasin: nomMagasin,
critere: libelleCritere(critereId),
profondeur,
examines: examine.length,
},
critereId,
);
const url = URL.createObjectURL(blob);
const lien = document.createElement("a");
lien.href = url;
lien.download = [
"Non_travailles",
nomFournisseur?.replace(/[^\w]+/g, "_"),
nomMagasin.replace(/[^\w]+/g, "_"),
`Top${profondeur}`,
new Date().toISOString().slice(0, 10),
].filter(Boolean).join("_") + ".xlsx";
lien.click();
URL.revokeObjectURL(url);
} catch (e) {
// Un export qui échoue en silence laisse croire au téléchargement.
console.error("[trous d'assortiment] export Excel :", e);
setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste.");
} finally {
setExportEnCours(false);
}
};
return (
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-5xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<PackageX className="w-5 h-5 shrink-0" style={{ color: "var(--accent-warning)" }} />
Produits non travaillés par un magasin
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
Sur le classement affiché — trié par{" "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleCritere(critereId)}</span>
{" — ni vente ni stock sur 12 mois."}
</p>
</DialogHeader>
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasin</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{sites.map((s) => (
<button
key={s}
type="button"
onClick={() => setSite(s)}
aria-pressed={site === s}
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors flex items-center gap-1.5"
style={site === s
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
>
<Store className="w-3 h-3" />
{SITE_LABELS[s]?.nom ?? s}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Profondeur</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{PROFONDEURS.map((p) => (
<button
key={p}
type="button"
onClick={() => setProfondeur(p)}
aria-pressed={profondeur === p}
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors tabular-nums"
style={profondeur === p
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
>
Top {p}
</button>
))}
</div>
{classement.length < profondeur && (
<span className="text-[11.5px]" style={{ color: "var(--text-muted)" }}>
({classement.length} lignes affichées seulement)
</span>
)}
</div>
{/* L'export reprend exactement la liste affichée : même magasin,
même profondeur, même classement. */}
<button
type="button"
onClick={exporter}
disabled={trous.length === 0 || exportEnCours}
title={trous.length === 0
? "Rien à exporter : aucun trou dans ce périmètre"
: `Exporter les ${trous.length} produits non travaillés au format Excel`}
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
>
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
{exportEnCours ? "Génération…" : "Export Excel"}
</button>
</div>
{exportErreur && (
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
{exportErreur}
</div>
)}
<div className="flex flex-wrap gap-2">
<TuileStat
label="Non travaillés"
valeur={`${trous.length} / ${examine.length}`}
indice={`à ${nomMagasin}`}
couleur={trous.length > 0 ? "var(--accent-warning)" : undefined}
icone={PackageX}
/>
<TuileStat
label="Vendus ailleurs"
valeur={nb(vendusAilleurs)}
indice={`déjà vendus chez ${autresMagasins} — le gisement le plus sûr`}
/>
<TuileStat
label="Déjà détenus"
valeur={nb(dejaTentes)}
indice="stock ou réception sur la période, sans vente : essayés, pas oubliés"
/>
</div>
{trous.length === 0 ? (
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement.
</div>
) : (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]" style={{ minWidth: 820 }}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>#</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Code</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Référence</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Désignation</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{libelleCritere(critereId)}
</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Ventes ailleurs</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Stock ailleurs</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Constat</th>
</tr>
</thead>
<tbody>
{trous.map(({ row, valeurCritere, rang, constat }) => (
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-muted)" }}>{rang}</td>
<td className="px-2.5 py-1.5 tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>{row.codein}</td>
<td className="px-2.5 py-1.5 font-mono text-[11.5px] whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.reference || "—"}
</td>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
<span className="font-semibold">{row.libelle1}</span>
{row.libelle3 && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
{formatCritere(critereId, valeurCritere)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap"
style={{ color: constat.ventesAilleurs > 0 ? "var(--accent-success)" : "var(--text-muted)" }}>
{constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"}
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[11.5px] leading-snug" style={{ color: "var(--text-muted)" }}>
Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}.
Un produit sans vente mais en stock n&apos;apparaît pas ici : le magasin le détient, c&apos;est un invendu, pas un trou d&apos;assortiment.
</p>
</DialogContent>
);
}
@@ -32,7 +32,7 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
};
return (
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
@@ -19,9 +19,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
return (
<div className="flex flex-col items-center">
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
<div className="flex flex-col items-center leading-tight">
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
</div>
);
@@ -153,7 +156,20 @@ export function FloatingSummaryBar() {
return (
<div
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
/*
* Dans le flux, plus en `fixed`.
*
* En `fixed` elle ne réservait aucune hauteur : seul un `pb-12` posé sur
* la page compensait, et il était trop court d'une quarantaine de pixels.
* Elle recouvrait donc la dernière ligne du tableau ET la barre de
* défilement horizontale — d'où l'impression qu'aucun ascenseur n'existait.
*
* Le `left-[264px]` était en prime calé sur une barre latérale dépliée,
* alors qu'elle est repliée par défaut (64 px) : 200 px de vide à gauche.
* En flux, la grille cède exactement la hauteur nécessaire et il n'y a
* plus aucune valeur magique à tenir en phase.
*/
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-4 gap-y-2 shadow-sm border rounded-xl transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
+9 -5
View File
@@ -58,6 +58,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
@@ -95,10 +96,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
if (!isMounted) return;
if (prevFournisseurRef.current !== codeFournisseur) {
setFilter("codeGamme", null);
setFilter("code3", null);
setCode3Filter(null);
prevFournisseurRef.current = codeFournisseur;
}
}, [codeFournisseur, setFilter, isMounted]);
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
const refreshToken = searchParams.get("_refresh");
useEffect(() => {
@@ -108,9 +109,11 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
// faire voyager relançait un chargement complet à chaque case cochée.
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (filters.code3) params.set("code3", filters.code3);
if (refreshToken) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
@@ -173,7 +176,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
@@ -206,7 +209,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
<div className="flex flex-col h-full space-y-3 min-h-0">
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-3.5 min-w-0">
@@ -304,6 +307,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
nomFournisseur={nomFournisseur}
/>
</div>
@@ -134,7 +134,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
fournisseurs={fournisseurs}
selectedCode={searchParams.get("fournisseur")}
onSelect={handleSupplierSelect}
className="w-[280px]"
className="w-[200px] xl:w-[280px]"
/>
{/* Store Selector */}
@@ -142,7 +142,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
magasins={magasins}
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
onSelect={handleStoreSelect}
className="w-[220px]"
className="w-[170px] xl:w-[220px]"
/>
{/* Search */}
@@ -156,12 +156,19 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
placeholder="Rechercher (code, désignation...)"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="apple-input w-72 pr-8"
// La croix native de `type="search"` (WebKit/Blink) est masquée :
// ce champ a déjà son propre bouton d'effacement juste à droite,
// et les deux apparaissaient côte à côte. On garde `type="search"`
// pour la sémantique ; le masquage est local, la recherche de
// l'en-tête n'ayant pas de bouton maison, elle garde le sien.
className="apple-input w-52 xl:w-72 pr-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-cancel-button]:hidden"
style={{ paddingLeft: "32px" }}
/>
{searchValue && (
<button
type="button"
onClick={() => setSearchValue("")}
aria-label="Effacer la recherche"
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
+529 -105
View File
@@ -13,21 +13,26 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal";
import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import {
getLast12Months,
getMonthsFromRows,
formatMonthLabel,
formatDate,
SITE_LABELS,
@@ -45,6 +50,8 @@ import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/net
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
isAdmin?: boolean;
/** Sert à nommer et documenter les extractions issues de la Grille. */
nomFournisseur?: string;
}
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
@@ -54,20 +61,88 @@ const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"qteReseau",
"nbMagasinsReseau",
"tendanceReseau",
"prixMoyenReseau",
"caParMagasinReseau",
"margePctReseau",
]);
function getQlikNetworkSortValue(value: number | null | undefined, columnId: string, sorting: SortingState): number | undefined {
if (value != null) return value;
/**
* Valeur de tri d'une colonne dont la donnée peut manquer : `undefined`, jamais
* zéro — un article que la source ne renseigne pas n'a pas vendu pour 0 €.
*
* Les colonnes qui l'emploient portent `sortUndefined: "last"`, qui garde ces
* lignes en bas dans LES DEUX SENS. Une sentinelle ±Infinity choisie d'après le
* sens de tri courant ne le pouvait pas : TanStack mémorise le résultat de
* l'accesseur dans `row._valuesCache` sans jamais le réévaluer, si bien que la
* sentinelle calculée au premier tri restait figée et renvoyait les lignes sans
* donnée en tête dès qu'on inversait le sens.
*/
const valeurTriable = (value: number | null | undefined): number | undefined => value ?? undefined;
const activeSort = sorting.find((sort) => sort.id === columnId);
if (!activeSort) return undefined;
/**
* Prix de vente moyen constaté sur le réseau.
*
* Même définition que la fiche produit (`/produits`) : CA réseau ÷ quantité
* vendue. Sans quantité, le rapport n'existe pas — on rend `null` plutôt que
* zéro, un prix nul se confondrait avec un article donné.
*/
function prixMoyenReseau(row: ProductRow): number | null {
const qte = row.qteReseau;
const ca = row.caReseau;
if (qte == null || ca == null || qte <= 0) return null;
return ca / qte;
}
// TanStack inverse le résultat en tri descendant. On utilise donc une
// sentinelle dépendante du sens de tri pour garder les valeurs absentes
// toujours en bas, en ascendant comme en descendant.
return activeSort.desc ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
/** Montant en euros au centime — l'usage sur des prix unitaires. */
const fmtEuro2 = (v: number) =>
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
/**
* Prix de vente à montrer pour le magasin consulté.
*
* `cube_pv` a une clé `(artnoid, site)`, mais notre base n'y met en pratique
* qu'une ligne par article : le prix n'est pas ventilé par magasin. La colonne
* doit donc rester lisible dans les deux cas de figure —
*
* · magasin consulté tarifé → SON prix ;
* · prix unique, non ventilé → ce prix vaut pour tous les magasins ;
* · plusieurs prix mais aucun pour ce magasin → rien, plutôt que celui du
* voisin ;
* · en « tous magasins » : le prix commun, et s'ils divergent le plus élevé
* assorti d'un signal. Une cellule ne peut afficher qu'un nombre, mais
* taire l'écart ferait passer le prix d'un magasin pour celui des deux.
*/
function prixVenteAffiche(row: ProductRow, activeMagasin: string): { valeur: number | null; divergent: boolean; detail: string | null } {
const entrees = Object.entries(row.prixVenteByStore ?? {});
// Aucun prix ventilé : reste le PV de la fiche article, s'il existe.
if (entrees.length === 0) return { valeur: row.prixVente ?? null, divergent: false, detail: null };
// Comparaison au centime : deux flottants issus du même prix peuvent différer
// dans les décimales lointaines sans que le prix affiché change.
const distincts = new Set(entrees.map(([, pv]) => Math.round(pv * 100)));
if (activeMagasin !== "TOTAL") {
const pv = row.prixVenteByStore?.[activeMagasin];
if (pv != null) return { valeur: pv, divergent: false, detail: null };
if (distincts.size === 1) {
return { valeur: entrees[0][1], divergent: false, detail: "prix unique, non ventilé par magasin" };
}
return { valeur: null, divergent: false, detail: null };
}
const detail = entrees.length > 1
? entrees
.sort(([a], [b]) => a.localeCompare(b))
.map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`)
.join(" · ")
: null;
return {
valeur: Math.max(...entrees.map(([, pv]) => pv)),
divergent: distincts.size > 1,
detail,
};
}
// =========================================================================
@@ -178,15 +253,62 @@ interface VirtualRowProps {
row: Row<ProductRow>;
rowHeight: number;
isSelected: boolean;
columnVisibility: Record<string, boolean>;
/** Identifiants des colonnes visibles, concaténés — cf. le corps du composant. */
columnsKey: string;
columnSizing: Record<string, number>;
}
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility, columnSizing }: VirtualRowProps) => {
/**
* Colonnes figées à gauche : celles qui disent QUELLE ligne on lit.
*
* Sans elles, dès qu'on défile vers les totaux ou les mois, la Désignation
* sort de l'écran et plus rien n'identifie la ligne — sur 2600 px de tableau
* pour ~1150 px visibles, c'est la moitié du temps.
*
* `libelle1` a été rapprochée de `codein` dans l'ordre des colonnes : le
* figeage exige des colonnes contiguës depuis la gauche, et geler en plus
* Référence et GTIN aurait immobilisé 636 px au lieu de 406.
*/
const COLONNES_FIGEES = new Set(["select", "codein", "libelle1"]);
/**
* Décalage `left` de chaque colonne figée, par cumul des largeurs.
*
* On s'arrête à la première colonne non figée : une colonne figée qui ne
* toucherait pas le bord flotterait au milieu du tableau. Si l'utilisateur
* masque « Code interne », le calcul se réajuste tout seul.
*/
function decalagesFiges(colonnes: { id: string; taille: number }[]): Map<string, number> {
const out = new Map<string, number>();
let cumul = 0;
for (const c of colonnes) {
if (!COLONNES_FIGEES.has(c.id)) break;
out.set(c.id, cumul);
cumul += c.taille;
}
return out;
}
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey, columnSizing }: VirtualRowProps) => {
// Uniquement la ligne concernée écoute son propre changement pour l'effet visuel
const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme);
void columnVisibility; // Force re-render via React.memo when visibility changes
void columnSizing; // Force re-render via React.memo when resizing columns
// ⚠️ Ces deux props ne sont pas lues : elles n'existent que pour rompre la
// mémoïsation. TanStack met ses objets `Row` en cache sur la seule identité
// des DONNÉES ; changer le jeu de colonnes ne leur donne aucune référence
// neuve. Sans un signal supplémentaire, `React.memo` laisserait les lignes
// déjà montées peindre l'ancien jeu de cellules pendant que l'en-tête, lui,
// affiche le nouveau — seules les lignes fraîchement virtualisées seraient à
// jour, et la grille se retrouverait à deux vitesses.
//
// La signature couvre TOUT changement de colonnes (case décochée dans le menu
// « Colonnes », bloc mensuel replié) là où l'ancien objet `columnVisibility`
// ne voyait que le menu.
void columnsKey;
void columnSizing; // idem, pour le redimensionnement des colonnes
const cellules = row.getVisibleCells();
const figes = decalagesFiges(cellules.map((c) => ({ id: c.column.id, taille: c.column.getSize() })));
const dernierFige = [...figes.keys()].at(-1);
return (
<tr
@@ -205,15 +327,25 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
}}
>
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
const gauche = figes.get(cell.column.id);
const estFige = gauche !== undefined;
const derniereFigee = estFige && cell.column.id === dernierFige;
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "gamme";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "prixVente" || cell.column.id === "gamme";
const size = cell.column.getSize();
return (
<td
key={cell.id}
className={cn(
"px-2 overflow-hidden flex items-center transition-colors group-hover/row:bg-white/5",
isSelected && "bg-transparent"
"px-2 overflow-hidden flex items-center transition-colors",
// Une cellule figée doit être OPAQUE : le reste de la ligne
// glisse dessous. Le survol passe donc par une teinte pleine,
// pas par le voile `bg-white/5` des cellules ordinaires.
estFige
? (isSelected
? "bg-[var(--accent-bg)]"
: "bg-[var(--bg-surface)] group-hover/row:bg-[var(--bg-elevated)]")
: cn("group-hover/row:bg-white/5", isSelected && "bg-transparent"),
)}
style={{
width: isFlexible ? "100%" : size,
@@ -221,7 +353,16 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
minWidth: size,
maxWidth: isFlexible ? "none" : size,
height: `${rowHeight}px`,
justifyContent: isCenter ? "center" : "flex-start"
justifyContent: isCenter ? "center" : "flex-start",
...(estFige ? {
position: "sticky" as const,
left: gauche,
// Au-dessus des cellules voisines, sous l'en-tête (z-10).
zIndex: 2,
// Trait de séparation : sans lui, rien ne dit où finit la
// zone figée et où commence ce qui défile.
borderRight: derniereFigee ? "1px solid var(--border-strong)" : undefined,
} : {}),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -245,36 +386,6 @@ interface CellDetailData {
receptions: number | null;
}
/**
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
*
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
*/
function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
label: string;
valeur: string;
indice?: string;
couleur?: string;
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
}) {
return (
<div className="rounded-xl px-3 py-2.5 flex-1 min-w-[140px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{Icone && <Icone className="w-4 h-4 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[20px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
/**
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
* mensuelles en petits multiples.
@@ -300,12 +411,12 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
: "—";
return (
<DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogContent className="max-w-2xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader>
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{row.libelle1}
</DialogTitle>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
Réseau · 12 mois glissants, mois en cours exclu
</p>
</DialogHeader>
@@ -396,26 +507,93 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
<span className="text-[10px] font-semibold uppercase tracking-wide text-center" style={{ color: "var(--text-muted)" }}>Stock fin mois</span>
</div>
</div>
{/*
* En « tous magasins », les trois tuiles ci-dessus sont des cumuls.
* Le détail par magasin est justement ce qu'on vient vérifier ici :
* savoir que le stock est de 40 ne dit pas s'il est réparti ou
* entièrement sur un seul site.
*/}
{activeMagasin === "TOTAL" && <VentilationParMagasin d={d} />}
</DialogContent>
);
}
/** Détail ventes / entrées / stock du mois, magasin par magasin. */
function VentilationParMagasin({ d }: { d: CellDetailData }) {
const sites = [...new Set([
...Object.keys(d.row.sales12mByStore ?? {}),
...Object.keys(d.row.stock12mByStore ?? {}),
])].sort();
if (sites.length === 0) return null;
return (
<div className="mt-3 rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Magasin</th>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Ventes</th>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Entrées</th>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Stock</th>
</tr>
</thead>
<tbody>
{sites.map((site) => {
const ventes = d.row.sales12mByStore?.[site]?.[d.monthKey] ?? 0;
const entrees = d.row.receptions12mByStore?.[site]?.[d.monthKey] ?? 0;
const stock = d.row.stock12mByStore?.[site]?.[d.monthKey] ?? 0;
return (
<tr key={site} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[site]?.nom ?? site}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{Math.round(ventes).toLocaleString("fr-FR")}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: entrees > 0 ? "rgb(16,185,129)" : "var(--text-muted)" }}>
{entrees > 0 ? `+${Math.round(entrees).toLocaleString("fr-FR")}` : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
{Math.round(stock).toLocaleString("fr-FR")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// =========================================================================
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const displayDensity = useGridStore((s) => s.displayDensity);
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
const activeMagasin = useGridStore((s) => s.activeMagasin);
const showMonthlySales = useGridStore((s) => s.showMonthlySales);
const setShowMonthlySales = useGridStore((s) => s.setShowMonthlySales);
const showPrices = useGridStore((s) => s.showPrices);
const setShowPrices = useGridStore((s) => s.setShowPrices);
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
const { code3, codeGamme } = filters;
if (!code3 && !codeGamme) return rows;
// 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;
// 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;
return rows.filter(r => {
if (code3 && r.code3 !== code3) return false;
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
if (codeGamme) {
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
const norm = g.trim() === "" ? "Aucune" : g;
@@ -437,10 +615,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
// État pour le détail d'une cellule mensuelle (modal)
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
// Détail 12 mois du produit, ouvert depuis la case de total
const [monthlyModal, setMonthlyModal] = useState<ProductRow | null>(null);
// Trous d'assortiment : ce qu'un magasin ne travaille pas, en haut du classement affiché
const [gapsOpen, setGapsOpen] = useState(false);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
const MONTHS_12 = useMemo(() => getLast12Months(), []);
// Les 12 mois viennent des données, pas de l'horloge du navigateur : les
// totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être
// aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts).
// Le calcul local ne sert que tant qu'aucune ligne n'est chargée.
const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]);
useEffect(() => {
setIsMounted(true);
@@ -520,32 +705,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
/>
),
},
{
accessorKey: "reference",
header: "Référence",
size: 110,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier la référence"
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "gtin",
header: "EAN / GTIN",
size: 120,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier l'EAN / GTIN"
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "libelle1",
header: "Désignation",
@@ -597,6 +756,32 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
);
},
},
{
accessorKey: "reference",
header: "Référence",
size: 110,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier la référence"
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "gtin",
header: "EAN / GTIN",
size: 120,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier l'EAN / GTIN"
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "libelle3",
header: "Famille",
@@ -614,7 +799,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "caReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caReseau, "caReseau", sorting),
accessorFn: (row) => valeurTriable(row.caReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 90,
cell: ({ row }) => {
@@ -628,7 +814,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "qteReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.qteReseau, "qteReseau", sorting),
accessorFn: (row) => valeurTriable(row.qteReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 80,
cell: ({ row }) => {
@@ -642,7 +829,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "nbMagasinsReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.nbMagasinsReseau, "nbMagasinsReseau", sorting),
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
size: 80,
cell: ({ row }) => {
@@ -683,9 +871,65 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
);
},
},
// Bloc « prix », repliable : le prix réellement pratiqué par le réseau, et
// celui du catalogue. Deux origines différentes, donc deux colonnes — les
// rapprocher d'un seul écart chiffré supposerait une même assiette (HT/TTC,
// remises), ce que les deux sources ne garantissent pas.
...(showPrices ? [
{
id: "prixMoyenReseau",
// Même définition que la fiche produit : CA réseau ÷ quantité vendue.
accessorFn: (row: ProductRow) => valeurTriable(prixMoyenReseau(row)),
sortUndefined: "last" as const,
// Sous-titre tenu court : les en-têtes ne rognent pas leur texte et
// un libellé trop long chevauche la colonne voisine. Le détail
// (« CA réseau ÷ quantité ») est dans l'infobulle des cellules.
header: () => <div className="text-center w-full">PV moyen<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = prixMoyenReseau(row.original);
return (
<div
className="text-center tabular-nums text-[12px] font-bold"
style={{ color: "var(--text-secondary)" }}
title={val != null
? `Prix de vente moyen constaté sur le réseau : CA réseau ÷ quantité vendue (12 mois glissants)`
: "Pas de quantité réseau sur la période : le prix moyen n'a pas de sens"}
>
{val != null ? fmtEuro2(val) : "-"}
</div>
);
},
},
{
id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">PV<br/><span className="text-[9px] opacity-60">Magasin</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
return (
<div
className="text-center tabular-nums text-[12px] font-bold flex items-center justify-center gap-0.5"
style={{ color: "var(--text-primary)" }}
title={valeur != null
? `Prix de vente en base (cube_pv)${detail ? ` — ${detail}` : ""}`
: "Aucun prix de vente en base pour cet article"}
>
{valeur != null ? fmtEuro2(valeur) : "-"}
{/* Prix différents d'un magasin à l'autre : le signaler, la
cellule ne peut en afficher qu'un. */}
{divergent && <span className="text-[10px] font-black" style={{ color: "var(--accent-warning)" }}>≠</span>}
</div>
);
},
},
] : []),
{
id: "caParMagasinReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caParMagasinReseau, "caParMagasinReseau", sorting),
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA / Mag<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 85,
cell: ({ row }) => {
@@ -699,7 +943,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "margePctReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.margePctReseau, "margePctReseau", sorting),
accessorFn: (row) => valeurTriable(row.margePctReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Marge %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 75,
cell: ({ row }) => {
@@ -714,7 +959,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
);
},
},
...MONTHS_12.map((monthKey) => ({
// Bloc mensuel repliable : les colonnes sont retirées du modèle, pas
// seulement cachées — inutile de faire calculer douze cellules par ligne
// à TanStack pour ne rien peindre. Le détail reste accessible d'un clic
// sur la case de total.
...(showMonthlySales ? MONTHS_12.map((monthKey) => ({
id: `month_${monthKey}`,
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
@@ -741,7 +990,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
/>
);
},
})),
})) : []),
{
id: "totalQuantite",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
@@ -751,14 +1000,25 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const qty = activeMagasin === "TOTAL"
? row.original.totalQuantite
: (row.original.quantiteByStore?.[activeMagasin] ?? 0);
// Cliquable → détail 12 mois : ventes, entrées en stock, stock par
// magasin. `stopPropagation` sinon le clic sélectionne la ligne.
return (
<div className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border" style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMonthlyModal(row.original);
}}
title="Voir le détail des 12 mois : ventes mensuelles, entrées en stock et stock par magasin"
className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border cursor-pointer transition-all hover:brightness-110 active:scale-95"
style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}
>
{Math.round(qty).toLocaleString("fr-FR")}
</div>
</button>
);
},
},
@@ -821,7 +1081,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
},
], [MONTHS_12, activeMagasin, isAdmin, sorting]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
// `sorting` ne figure plus en dépendance : plus aucun accesseur ne le lit
// depuis que `sortUndefined` remplace les sentinelles. Les colonnes ne se
// reconstruisent donc plus à chaque clic sur un en-tête.
], [MONTHS_12, activeMagasin, isAdmin, showMonthlySales, showPrices]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
const table = useReactTable({
data: filteredData,
@@ -856,7 +1119,35 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
});
const visibleColumns = table.getVisibleLeafColumns();
const totalWidth = visibleColumns.reduce((s, c) => s + ((c.columnDef as { size?: number }).size ?? 150), 0);
// `getSize()` et non `columnDef.size` : le redimensionnement de colonnes est
// actif et persisté. En lisant la taille déclarée, la largeur plancher de la
// table restait figée sur l'ancienne somme dès qu'on élargissait une colonne,
// ce qui rognait les dernières et raccourcissait la course de défilement.
const totalWidth = visibleColumns.reduce((s, c) => s + c.getSize(), 0);
// Signature du jeu de colonnes affiché : elle sert de signal de re-rendu aux
// lignes virtuelles (cf. `GridRow`). Calculée une fois par rendu de grille,
// pas une fois par ligne.
const columnsKey = visibleColumns.map((c) => c.id).join("|");
/** Blocs de colonnes affichés — badge du menu « Vues ». */
const nbVues = (showMonthlySales ? 1 : 0) + (showPrices ? 1 : 0);
/**
* Le haut du classement TEL QU'IL EST AFFICHÉ, pour les trous d'assortiment.
*
* Les lignes viennent du modèle trié et filtré de TanStack : le tri en cours
* — CA, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a servi
* à classer est relue par `getValue` sur la colonne triée. Tronqué à la
* profondeur maximale offerte : au-delà, on copierait 130 000 lignes pour en
* regarder 500. Calculé seulement quand la fenêtre est ouverte.
*/
const classementAffiche: LigneClassee[] = useMemo(() => {
if (!gapsOpen) return [];
const critereId = sorting[0]?.id ?? null;
return tableRows.slice(0, PROFONDEUR_MAX).map((r) => ({
row: r.original,
valeurCritere: critereId ? r.getValue(critereId) : null,
}));
}, [gapsOpen, tableRows, sorting]);
if (!isMounted) {
return (
@@ -876,6 +1167,76 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<>
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2.5">
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
<button
onClick={() => setGapsOpen(true)}
disabled={tableRows.length === 0}
title="Voir les produits qu'un magasin ne travaille pas — ni vente ni stock — en haut du classement affiché"
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary disabled:opacity-50"
style={{ background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
>
<PackageX className="w-4 h-4" />
<span className="hidden lg:inline">Non travaillés</span>
</button>
{/*
* « Vues » replie des BLOCS de colonnes d'un geste, là où le menu
* « Colonnes » voisin agit colonne par colonne. Douze cellules
* mensuelles ou deux prix ne se masquent pas une par une.
*/}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary"
style={nbVues > 0
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
: { background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
title="Afficher ou masquer les blocs de colonnes : ventes mensuelles, prix"
aria-label="Afficher ou masquer les blocs de colonnes"
>
{nbVues > 0 ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
<span className="hidden lg:inline">Vues</span>
<span className="text-[11px] font-bold tabular-nums opacity-70">{nbVues}/2</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] z-50">
<DropdownMenuLabel className="text-[11px] uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Blocs de colonnes
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showMonthlySales}
onCheckedChange={(v) => setShowMonthlySales(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<CalendarRange className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Ventes mensuelles</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
12 mois · détail au clic sur le total
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showPrices}
onCheckedChange={(v) => setShowPrices(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Prix</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
PV moyen réseau · PV central
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -908,13 +1269,18 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
);
})}
</DropdownMenuContent>
</DropdownMenu>,
</DropdownMenu>
</div>,
portalContainer
)}
<div
ref={tableContainerRef}
className="h-full w-full overflow-auto rounded-[14px] relative"
// `tabIndex` : sans lui, les flèches, Origine et Fin ne défilent pas
// la grille. Il ne restait que Maj+molette, que personne ne devine.
tabIndex={0}
aria-label="Tableau des articles — défilement horizontal sur 12 mois"
className="h-full w-full overflow-auto rounded-[14px] relative grid-scroll"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
@@ -930,21 +1296,44 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
{(() => {
const figesEntete = decalagesFiges(
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
);
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
return headerGroup.headers.map((header) => {
const gaucheEntete = figesEntete.get(header.column.id);
const enteteFigee = gaucheEntete !== undefined;
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "gamme";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
const size = header.getSize();
return (
<th
key={header.id}
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5 relative group/header"
className={cn(
"px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors relative group/header",
!enteteFigee && "hover:bg-white/5",
)}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
justifyContent: isCenter ? "center" : "flex-start",
...(enteteFigee ? {
position: "sticky" as const,
left: gaucheEntete,
zIndex: 2,
// Le fond du <thead> est un dégradé translucide : sans
// fond propre, les autres intitulés défileraient à
// travers l'en-tête figé. On reprend le même dégradé
// pour que la bande reste d'un seul tenant.
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderRight: header.column.id === dernierFigeEntete
? "1px solid var(--border-strong)"
: undefined,
} : {}),
}}
onClick={header.column.getToggleSortingHandler()}
>
@@ -970,11 +1359,22 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
)}
</th>
);
})}
});
})()}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{/*
* La clé porte AUSSI la signature des colonnes : changer de jeu de
* colonnes remonte le corps du tableau au lieu de compter sur la
* comparaison de props de `React.memo`. Une ligne mémoïsée qui rate
* le signal continue de peindre l'ancien jeu de cellules, et la
* grille se retrouve à deux vitesses — en-tête à jour, corps en
* retard. Un remontage ne coûte que les ~30 lignes virtualisées, et
* le redimensionnement (continu) ne le déclenche pas : les
* identifiants de colonnes ne changent pas.
*/}
<tbody key={`${displayDensity}|${columnsKey}`} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const isSelected = row.getIsSelected();
@@ -986,7 +1386,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
columnVisibility={columnVisibility}
columnsKey={columnsKey}
columnSizing={columnSizing}
/>
</div>
@@ -1004,6 +1404,30 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
</Dialog>
{/* Trous d'assortiment sur le haut du classement affiché */}
<Dialog open={gapsOpen} onOpenChange={setGapsOpen}>
{gapsOpen && (
<AssortmentGapsModal
classement={classementAffiche}
critereId={sorting[0]?.id ?? null}
mois={MONTHS_12}
magasinInitial={activeMagasin}
nomFournisseur={nomFournisseur}
onClose={() => setGapsOpen(false)}
/>
)}
</Dialog>
{/* Modal détail 12 mois (ventes / entrées / stock par magasin) */}
<Dialog open={monthlyModal !== null} onOpenChange={(open) => { if (!open) setMonthlyModal(null); }}>
{monthlyModal !== null && (
<ProductMonthlyModal
row={monthlyModal}
mois={MONTHS_12}
activeMagasin={activeMagasin}
onClose={() => setMonthlyModal(null)}
/>
)}
</Dialog>
</>
);
}
+21 -19
View File
@@ -135,7 +135,9 @@ export function NetworkLineChart({
}
const W = 520;
const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12;
// Bandeau et axe dimensionnés pour la typographie ci-dessous : agrandir les
// polices sans élargir ces bandes ferait chevaucher titres et courbes.
const padL = 16, padR = 16, axeH = 24, enteteH = 21, ecart = 14;
const plotW = W - padL - padR;
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
@@ -170,7 +172,7 @@ export function NetworkLineChart({
key={v}
type="button"
onClick={() => setVue(v)}
className="px-2.5 py-0.5 text-[10px] font-semibold rounded-md transition-colors capitalize"
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors capitalize"
style={vue === v
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
@@ -204,11 +206,11 @@ export function NetworkLineChart({
return (
<g key={f.titre}>
{/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
<rect x={padL} y={f.hautEntete + 3} width={12} height={3} rx={1.5} fill={f.couleur} />
<text x={padL + 18} y={f.hautEntete + 7} fontSize={9.5} fontWeight={600} fill="var(--text-secondary)" dominantBaseline="middle">
<rect x={padL} y={f.hautEntete + 6} width={14} height={4} rx={2} fill={f.couleur} />
<text x={padL + 20} y={f.hautEntete + 9} fontSize={13} fontWeight={700} fill="var(--text-secondary)" dominantBaseline="middle">
{f.titre}
</text>
<text x={padL + plotW} y={f.hautEntete + 7} textAnchor="end" fontSize={9} fill="var(--text-muted)" dominantBaseline="middle">
<text x={padL + plotW} y={f.hautEntete + 9} textAnchor="end" fontSize={11.5} fill="var(--text-muted)" dominantBaseline="middle">
max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""}
</text>
@@ -242,14 +244,14 @@ export function NetworkLineChart({
// bandeau de la facette (constaté au rendu : « 58 » par-dessus
// « Qté / magasin »). Dans ce cas elle bascule SOUS le point.
const yPoint = f.y(f.valeurs[i]);
const dessus = yPoint - 8 >= f.haut + 8;
const dessus = yPoint - 11 >= f.haut + 8;
return (
<text
key={`lab-${f.titre}-${i}`}
x={x(i)}
y={dessus ? yPoint - 8 : yPoint + 13}
y={dessus ? yPoint - 10 : yPoint + 16}
textAnchor={ancrage(i)}
fontSize={9}
fontSize={12.5}
fontWeight={700}
fill="var(--text-primary)"
>
@@ -283,9 +285,9 @@ export function NetworkLineChart({
<text
key={`mois-${lab}`}
x={x(i)}
y={H - 5}
y={H - 7}
textAnchor={ancrage(i)}
fontSize={8.5}
fontSize={11}
fill={survol === i ? "var(--text-primary)" : "var(--text-muted)"}
fontWeight={survol === i ? 700 : 400}
>
@@ -300,8 +302,8 @@ export function NetworkLineChart({
plus jamais. `pointerEvents=none` laisse passer le survol vers les
cibles en dessous. */}
{survol != null && (() => {
const largeur = 158;
const hauteur = 16 + disposees.length * 13 + 8;
const largeur = 196;
const hauteur = 22 + disposees.length * 17 + 8;
// La bulle bascule à gauche du réticule quand elle déborderait à droite.
const gauche = x(survol) + 10 + largeur > W - 4;
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
@@ -311,18 +313,18 @@ export function NetworkLineChart({
x={bx} y={4} width={largeur} height={hauteur} rx={8}
fill="var(--bg-elevated)" stroke="var(--border)" strokeWidth={1}
/>
<text x={bx + 9} y={17} fontSize={9.5} fontWeight={700} fill="var(--text-primary)">
<text x={bx + 11} y={21} fontSize={12.5} fontWeight={700} fill="var(--text-primary)">
{fmtMonthShort(labels[survol])}
</text>
{disposees.map((f, k) => (
<g key={`bulle-${f.titre}`}>
<rect x={bx + 9} y={26 + k * 13} width={8} height={3} rx={1.5} fill={f.couleur} />
<text x={bx + 22} y={30 + k * 13} fontSize={9} fill="var(--text-secondary)">
<rect x={bx + 11} y={33 + k * 17} width={10} height={4} rx={2} fill={f.couleur} />
<text x={bx + 26} y={38 + k * 17} fontSize={11.5} fill="var(--text-secondary)">
{f.titre}
</text>
<text
x={bx + largeur - 9} y={30 + k * 13}
textAnchor="end" fontSize={9} fontWeight={700} fill="var(--text-primary)"
x={bx + largeur - 11} y={38 + k * 17}
textAnchor="end" fontSize={11.5} fontWeight={700} fill="var(--text-primary)"
>
{fmt(f.valeurs[survol], f.decimales ?? 0)}
</text>
@@ -353,7 +355,7 @@ export function NetworkLineChart({
du graphique et défile à l'intérieur : la carte garde sa taille, et
aucune valeur n'est réservée au survol. */
<div style={{ height: H }} className="overflow-y-auto overflow-x-auto">
<table className="w-full text-[11px] tabular-nums" style={{ borderCollapse: "collapse" }}>
<table className="w-full text-[13px] tabular-nums" style={{ borderCollapse: "collapse" }}>
<thead className="sticky top-0" style={{ background: "var(--bg-surface)" }}>
<tr style={{ color: "var(--text-muted)" }}>
<th className="text-left font-medium py-1 pr-2">Mois</th>
@@ -408,7 +410,7 @@ export function DualLineChart({
<div className="w-full overflow-x-auto">
<div className="flex items-center gap-4 mb-1">
{series.map((s) => (
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span key={s.name} className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
{s.name}
</span>
@@ -2,7 +2,13 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Filter } from "lucide-react";
import { ChevronDown, Filter, Search, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface NomenclatureFilterProps {
@@ -10,33 +16,143 @@ interface NomenclatureFilterProps {
className?: string;
}
/**
* Filtre multi-nomenclatures de la Grille.
*
* Une liste déroulante à cases plutôt qu'un `<select>` simple : on veut pouvoir
* en retenir plusieurs, et surtout partir de « tout » pour retirer les postes
* dont on ne veut pas — ce qui est la manière naturelle de travailler quand un
* fournisseur en compte des dizaines.
*
* Convention : `null` = aucun filtre (tout s'affiche), `[]` = rien de coché
* (aucune ligne). Les distinguer évite la valeur sentinelle qu'imposerait un
* simple tableau, et rend « Tout décocher » possible sans ambiguïté.
*/
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
const code3 = useGridStore((s) => s.filters.code3);
const setFilter = useGridStore((s) => s.setFilter);
const selection = useGridStore((s) => s.filters.code3);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const [recherche, setRecherche] = React.useState("");
/** `null` = tout retenu : les cases sont alors toutes cochées. */
const tout = selection === null;
const selectionnes = React.useMemo(() => new Set(selection ?? []), [selection]);
const visibles = React.useMemo(() => {
const q = recherche.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => `${o.code} ${o.label}`.toLowerCase().includes(q));
}, [options, recherche]);
const basculer = (code: string, coche: boolean) => {
// Décocher depuis l'état « tout » doit retirer ce seul poste : on matérialise
// donc la liste complète, sans quoi le premier décochage ne ferait rien.
const base = tout ? options.map((o) => o.code) : (selection ?? []);
const suivant = coche ? [...base, code] : base.filter((c) => c !== code);
// Tout re-cocher revient à ne plus filtrer : on repasse à `null` pour que
// l'état reste lisible (et l'URL propre).
setCode3Filter(suivant.length === options.length ? null : suivant);
};
const libelle = tout
? "Toute la nomenclature"
: selectionnes.size === 0
? "Aucune nomenclature"
: selectionnes.size === 1
? (options.find((o) => o.code === selection![0])?.label ?? selection![0])
: `${selectionnes.size} / ${options.length} nomenclatures`;
return (
<div className={cn("relative group", className)}>
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<Filter className="h-3.5 w-3.5 opacity-40 group-focus-within:opacity-100 transition-opacity" style={{ color: "var(--text-primary)" }} />
</div>
<div className={cn("relative", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="apple-input w-full min-w-[240px] flex items-center gap-2 text-left"
style={{ fontSize: "12px", height: "36px", paddingLeft: "12px", paddingRight: "12px" }}
>
<Filter className="h-3.5 w-3.5 opacity-40 shrink-0" />
<span className="flex-1 truncate" style={{ color: tout ? undefined : "var(--text-primary)" }}>
{libelle}
</span>
{!tout && (
<span
role="button"
tabIndex={0}
title="Tout réafficher"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); } }}
className="shrink-0 rounded p-0.5 hover:bg-[var(--bg-elevated)]"
>
<X className="h-3.5 w-3.5 opacity-60" />
</span>
)}
<ChevronDown className="h-4 w-4 opacity-40 shrink-0" />
</button>
</DropdownMenuTrigger>
<select
value={code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-12 appearance-none w-full min-w-[240px]"
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
>
<option value="">Toute la nomenclature</option>
{options.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.code} — {opt.label}
</option>
))}
</select>
<DropdownMenuContent align="start" className="w-[320px] max-h-[420px] overflow-y-auto p-0">
<div className="sticky top-0 z-10 p-2 space-y-2" style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 opacity-40" />
<input
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="Filtrer les nomenclatures"
className="w-full rounded-lg pl-7 pr-2 py-1.5 text-[12px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
</div>
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setCode3Filter(null)}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout cocher
</button>
<button
type="button"
// Un code vide ne correspond à aucune ligne : c'est ainsi qu'on
// exprime « rien de coché » sans confondre avec « pas de filtre ».
onClick={() => setCode3Filter([])}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout décocher
</button>
{recherche.trim() && visibles.length > 0 && (
<button
type="button"
onClick={() => setCode3Filter(visibles.map((o) => o.code))}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)] ml-auto"
style={{ color: "var(--accent)" }}
>
Ne garder que ces {visibles.length}
</button>
)}
</div>
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none opacity-40">
<ChevronDown className="h-4 w-4" />
</div>
{visibles.length === 0 ? (
<p className="p-3 text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune nomenclature ne correspond.</p>
) : (
visibles.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.code}
className="text-xs cursor-pointer"
checked={tout || selectionnes.has(opt.code)}
onCheckedChange={(v) => basculer(opt.code, !!v)}
onSelect={(e: Event) => e.preventDefault()}
>
<span className="font-mono opacity-60 mr-1.5">{opt.code}</span>
{opt.label}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,403 @@
"use client";
/**
* CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ».
*
* La colonne de total dit COMBIEN, jamais COMMENT. On empile donc les trois
* séries qui expliquent le total, dans l'ordre où on les lit : ce qui est sorti
* (ventes), ce qui est entré (réceptions), puis ce qui reste — magasin par
* magasin, parce qu'un stock de 40 réparti sur deux sites et un stock de 40
* bloqué sur un seul ne se pilotent pas pareil.
*
* SVG fait main comme le reste des graphiques du dossier : aucune librairie.
*/
import React from "react";
import { ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { formatMonthLabel, formatDate, SITE_LABELS } from "@/features/grid/lib/months";
import type { ProductRow } from "@/types/grid";
/** Couleurs de séries : slots de la palette catégorielle validée (cf. `globals.css`). */
const COULEUR_VENTES = "var(--viz-1)";
const COULEUR_ENTREES = "var(--viz-3)";
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR");
/**
* Barres mensuelles sur une ligne de zéro explicite.
*
* Les quantités peuvent être NÉGATIVES (retours supérieurs aux ventes sur un
* mois) : l'échelle part donc du minimum réel et les barres se dessinent de part
* et d'autre du zéro, sinon un mois de retours sortirait du cadre par le bas.
*
* Chaque barre porte sa valeur écrite : douze nombres restent lisibles à cette
* largeur, et aucune valeur n'est ainsi accessible au seul survol.
*/
function BarresMensuelles({ mois, valeurs, couleur, aria }: {
mois: string[];
valeurs: number[];
couleur: string;
aria: string;
}) {
const n = valeurs.length;
const min = Math.min(0, ...valeurs);
const max = Math.max(0, ...valeurs);
// Une barre négative écrit sa valeur SOUS elle : sans marge basse
// supplémentaire, ce nombre se poserait sur les libellés de mois.
const W = 660, hautPlot = 104, padH = 18;
const padB = min < 0 ? 42 : 24;
const H = padH + hautPlot + padB;
const etendue = max - min || 1;
const y = (v: number) => padH + hautPlot - ((v - min) / etendue) * hautPlot;
const yZero = y(0);
const pas = W / n;
const largeur = Math.min(pas * 0.6, 34);
return (
<div className="overflow-x-auto min-w-0">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
height={H}
preserveAspectRatio="xMidYMid meet"
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
// base et décale la section suivante.
style={{ minWidth: 520, display: "block" }}
role="img"
aria-label={aria}
>
<line x1={0} y1={yZero} x2={W} y2={yZero} stroke="var(--border)" strokeWidth={1} />
{valeurs.map((v, i) => {
const cx = pas * i + pas / 2;
const yVal = y(v);
const haut = Math.min(yVal, yZero);
const hauteur = Math.max(Math.abs(yVal - yZero), 1.5);
return (
<g key={mois[i]}>
{v !== 0 && (
<rect
x={cx - largeur / 2}
y={haut}
width={largeur}
height={hauteur}
rx={2.5}
fill={couleur}
opacity={0.9}
/>
)}
<text
x={cx}
y={v < 0 ? yZero + hauteur + 12 : haut - 5}
textAnchor="middle"
fontSize={11.5}
fontWeight={700}
fill={v === 0 ? "var(--text-muted)" : "var(--text-secondary)"}
>
{fmt(v)}
</text>
<text x={cx} y={H - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
{formatMonthLabel(mois[i])}
</text>
</g>
);
})}
</svg>
</div>
);
}
/** En-tête de section : repère coloré + titre + cumul de la série. */
function EnteteSection({ icone: Icone, titre, detail, couleur }: {
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
titre: string;
detail: string;
couleur: string;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="rounded-sm shrink-0" style={{ width: 14, height: 4, background: couleur }} />
<Icone className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<h3 className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
<span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>
</div>
);
}
/**
* Une série mensuelle ventilée par magasin, un magasin par ligne.
*
* Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock
* (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul).
*
* La ligne de total est recalculée depuis les lignes affichées : un total qui ne
* correspond pas à la somme visible fait douter du tableau entier.
*/
function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: {
row: ProductRow;
mois: string[];
activeMagasin: string;
/** Série par magasin (site → mois → valeur). */
parSite: Record<string, Record<string, number>> | undefined;
/** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */
agrege: Record<string, number>;
/** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */
sousLigne: (site: string | null, valeurs: number[]) => string;
/** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */
cumul?: boolean;
aria: string;
}) {
const sites = [...new Set([
...Object.keys(row.stock12mByStore ?? {}),
...Object.keys(row.sales12mByStore ?? {}),
])].sort();
// Sans ventilation par site (produit jamais mouvementé, données partielles),
// on montre au moins la série agrégée plutôt qu'un tableau vide.
const lignes = sites.length > 0
? sites.map((site) => {
const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0);
return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) };
})
: [(() => {
const valeurs = mois.map((m) => agrege[m] ?? 0);
return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) };
})()];
const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0));
const somme = (v: number[]) => v.reduce((t, x) => t + x, 0);
const cellule = (v: number, key: string, gras = false) => (
<td
key={key}
className={`px-1.5 py-1.5 text-right tabular-nums${gras ? " font-bold" : ""}`}
style={{ color: v === 0 && !gras ? "var(--text-muted)" : "var(--text-primary)" }}
>
{fmt(v)}
</td>
);
return (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
Magasin
</th>
{mois.map((m) => (
<th key={m} className="text-right px-1.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{formatMonthLabel(m)}
</th>
))}
{cumul && (
<th className="text-right px-2 py-1.5 font-bold whitespace-nowrap" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
12 m
</th>
)}
</tr>
</thead>
<tbody>
{lignes.map((l) => {
const surligne = l.actif && activeMagasin !== "TOTAL";
return (
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
<div className="font-semibold">{l.nom}</div>
{/* L'information secondaire (dernière entrée, mois avec
vente) est écrite, pas réservée au survol. */}
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
</td>
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(l.valeurs))}
</td>
)}
</tr>
);
})}
{lignes.length > 1 && (
<tr style={{ borderTop: "1px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
Total
</td>
{totaux.map((v, i) => cellule(v, mois[i], true))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(totaux))}
</td>
)}
</tr>
)}
</tbody>
</table>
</div>
);
}
/**
* Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin.
*
* Les graphiques suivent le magasin actif de la Grille (cohérence avec la case
* cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par
* magasin, c'est leur objet.
*
* `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) :
* c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la
* Grille.
*/
export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
row: ProductRow;
mois: string[];
activeMagasin: string;
onClose: () => void;
}) {
const surTotal = activeMagasin === "TOTAL";
const libelleMagasin = surTotal ? "Tous magasins" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
const ventes = mois.map((m) => (surTotal
? row.sales12m[m]
: row.sales12mByStore?.[activeMagasin]?.[m]) ?? 0);
const entrees = mois.map((m) => (surTotal
? row.receptions12m[m]
: row.receptions12mByStore?.[activeMagasin]?.[m]) ?? 0);
const stocks = mois.map((m) => (surTotal
? row.stock12m[m]
: row.stock12mByStore?.[activeMagasin]?.[m]) ?? 0);
// Sur un magasin donné, la dernière entrée du réseau n'est pas la sienne.
const derniereEntree = surTotal
? row.derniereLivraison
: row.derniereLivraisonByStore?.[activeMagasin];
const totalVentes = ventes.reduce((t, v) => t + v, 0);
const totalEntrees = entrees.reduce((t, v) => t + v, 0);
const stockFin = stocks[stocks.length - 1] ?? 0;
const moisAvecVente = ventes.filter((v) => v !== 0).length;
// Couverture : combien de mois le stock actuel tient-il au rythme des 12
// derniers mois ? Sans vente, le ratio n'existe pas — on ne l'invente pas.
const rythme = totalVentes / mois.length;
const couverture = rythme > 0 ? stockFin / rythme : null;
return (
<DialogContent
// `grid-cols-1` : sans colonne explicite, la grille de la modale se
// dimensionne sur son contenu le plus large (le tableau des magasins)
// et déborde par la droite au lieu de le laisser défiler.
className="max-w-[calc(100%-2rem)] sm:max-w-4xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{row.libelle1}
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
{row.codein}
{row.reference ? ` · ${row.reference}` : ""}
{" · "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleMagasin}</span>
{" · 12 mois glissants, mois en cours exclu"}
</p>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<TuileStat
label="Ventes 12 m"
valeur={fmt(totalVentes)}
indice={`${moisAvecVente} mois avec vente · ${rythme.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} / mois`}
icone={ShoppingCart}
/>
<TuileStat
label="Entrées 12 m"
valeur={totalEntrees > 0 ? `+${fmt(totalEntrees)}` : "—"}
indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"}
couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined}
icone={PackageOpen}
/>
<TuileStat
label="Stock fin de période"
valeur={fmt(stockFin)}
indice={couverture != null
? `≈ ${couverture.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} mois de couverture`
: "pas de vente sur la période"}
icone={Warehouse}
/>
</div>
<section className="space-y-1.5">
<EnteteSection
icone={ShoppingCart}
titre="Ventes mensuelles"
detail={`${fmt(totalVentes)} unités sur 12 mois`}
couleur={COULEUR_VENTES}
/>
<BarresMensuelles
mois={mois}
valeurs={ventes}
couleur={COULEUR_VENTES}
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
/>
{/* Le graphique donne la forme, le tableau donne les chiffres par
magasin — et son cumul « 12 m » est le même total que la tuile
et que la case cliquée dans la Grille. */}
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.sales12mByStore}
agrege={row.sales12m}
sousLigne={(_site, valeurs) => {
const n = valeurs.filter((v) => v !== 0).length;
return n === 0 ? "aucune vente" : `${n} mois avec vente`;
}}
cumul
aria={`Ventes mensuelles de ${row.libelle1} par magasin`}
/>
</section>
<section className="space-y-1">
<EnteteSection
icone={PackageOpen}
titre="Entrées en stock"
detail={totalEntrees > 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"}
couleur={COULEUR_ENTREES}
/>
<BarresMensuelles
mois={mois}
valeurs={entrees}
couleur={COULEUR_ENTREES}
aria={`Entrées en stock de ${row.libelle1} sur 12 mois`}
/>
</section>
<section className="space-y-1.5">
<EnteteSection
icone={Warehouse}
titre="Stock par magasin"
detail="stock de fin de mois"
couleur="var(--border-strong)"
/>
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.stock12mByStore}
agrege={row.stock12m}
sousLigne={(site) => {
// La date de dernière entrée explique un stock qui ne bouge plus.
const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison;
return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée";
}}
aria={`Stock de fin de mois de ${row.libelle1} par magasin`}
/>
</section>
</DialogContent>
);
}
@@ -0,0 +1,36 @@
"use client";
import React from "react";
/**
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
*
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
*
* Extraite de `heatmap-grid.tsx` pour être partagée entre la modale de tendance
* réseau et la modale de détail mensuel produit. Aucun changement de rendu.
*/
export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
label: string;
valeur: string;
indice?: string;
couleur?: string;
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
}) {
return (
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-1">
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
+26
View File
@@ -30,6 +30,32 @@ export function getLast12Months(): string[] {
return months;
}
/**
* Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
* séries mensuelles des lignes reçues.
*
* `getLast12Months()` recalcule la fenêtre depuis l'horloge du navigateur ; or
* les totaux (`totalQuantite`, `quantiteByStore`…) sont figés côté serveur, sur
* SA fenêtre. Dès que les deux divergent — onglet resté ouvert au changement de
* mois, lignes servies depuis le cache, horloges décalées — un mois de ventes
* disparaît des colonnes tout en restant compté dans le total : la case
* « Tot. 12m » affiche 28 quand les douze cases visibles ne font que 20.
* Les clés des données sont la seule référence commune : on les prend.
*
* Renvoie `null` si aucune ligne ne porte de série (fournisseur sans vente,
* chargement en cours) — l'appelant retombe alors sur `getLast12Months()`.
*/
export function getMonthsFromRows(rows: ReadonlyArray<{ sales12m?: Record<string, number> }>): string[] | null {
for (const r of rows) {
const keys = Object.keys(r.sales12m ?? {}).filter((k) => /^\d{6}$/.test(k));
if (keys.length < 12) continue;
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
// première ligne complète suffit.
return keys.sort().slice(-12);
}
return null;
}
/** `"202601"` → `"Jan 26"`. */
export function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
+54 -2
View File
@@ -20,6 +20,19 @@ interface GridState {
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
columnSizing: Record<string, number>;
/**
* Bloc des 12 colonnes mensuelles affiché ou non dans la Grille.
*
* Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas
* une manipulation, et l'état « bloc replié » doit survivre au renouvellement
* de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque
* mois, un drapeau ne change pas.
*/
showMonthlySales: boolean;
setShowMonthlySales: (visible: boolean) => void;
/** Bloc des deux colonnes de prix (PV moyen réseau, PV central) affiché ou non. */
showPrices: boolean;
setShowPrices: (visible: boolean) => void;
/** Currently displayed store: "TOTAL" | "292" | "579" */
activeMagasin: string;
@@ -33,7 +46,9 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
setActiveGridQuery: (query: string) => void;
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
@@ -70,6 +85,12 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
};
}
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
type GridPersisted = Pick<
GridState,
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" | "showPrices"
>;
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
@@ -98,6 +119,11 @@ export const useGridStore = create<GridState>()(
activeGridQuery: "",
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
showPrices: true,
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
setShowPrices: (visible) => set({ showPrices: visible }),
setActiveMagasin: (code) => {
set({ activeMagasin: code });
@@ -172,6 +198,9 @@ export const useGridStore = create<GridState>()(
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
setCode3Filter: (codes) => {
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
restoreSnapshot: (changes) => {
@@ -214,14 +243,37 @@ export const useGridStore = create<GridState>()(
}),
{
name: "collectflow-grid-storage",
/**
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
* apparaît vide. Le numéro de version force la reprise de l'existant.
*/
version: 1,
migrate: (persisted: unknown, version: number): GridPersisted => {
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
if (version < 1 && etat.filters) {
const c = etat.filters.code3;
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
}
// Drapeaux ajoutés après coup : un état persisté antérieur n'en a
// pas, et une valeur `undefined` écraserait la valeur par défaut
// à la fusion (le bloc disparaîtrait sans raison).
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
return etat;
},
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
partialize: (state) => ({
partialize: (state): GridPersisted => ({
filters: state.filters,
displayDensity: state.displayDensity,
draftChanges: state.draftChanges,
activeGridQuery: state.activeGridQuery,
columnVisibility: state.columnVisibility,
columnSizing: state.columnSizing,
showMonthlySales: state.showMonthlySales,
showPrices: state.showPrices,
}),
}
)
+20
View File
@@ -0,0 +1,20 @@
/**
* CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur.
*
* Sert à armer le minuteur de la synchronisation nocturne. L'import est
* dynamique et conditionné au runtime Node : `register()` est aussi évalué
* dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base
* n'ont de sens.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
try {
const { startSyncTimer } = await import("@/features/admin/api/sync-timer");
startSyncTimer();
} catch (e) {
// Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas,
// l'application doit rester utilisable et la synchro manuelle disponible.
console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e));
}
}
+6 -1
View File
@@ -31,10 +31,15 @@ export interface Pagination {
limit: number;
total: number;
totalPages: number;
/** `true` s'il reste des pages à lire — évite d'avoir à comparer page et totalPages. */
hasMore: boolean;
}
export function buildPagination(page: number, limit: number, total: number): Pagination {
return { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)) };
const totalPages = Math.max(1, Math.ceil(total / limit));
// `hasMore` explicite : un agent qui doit comparer page et totalPages oublie
// souvent de le faire et conclut à tort qu'il a tout récupéré.
return { page, limit, total, totalPages, hasMore: page < totalPages };
}
/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */
+39 -1
View File
@@ -13,7 +13,13 @@ const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
export const paginationShape = {
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(500).default(100),
/**
* Aucun plafond : l'appelant décide de la taille qu'il veut recevoir.
* Le défaut reste modeste (100) pour que les endpoints de recherche, non
* bornés par un fournisseur, ne renvoient pas tout le catalogue par accident.
* `/grid` redéfinit ce paramètre : sans `limit`, il renvoie tout.
*/
limit: z.coerce.number().int().min(1).default(100),
};
const sortShape = {
@@ -40,6 +46,12 @@ export const gridQuerySchema = z.object({
code1: z.string().min(1).optional(),
code2: z.string().min(1).optional(),
code3: z.string().min(1).optional(),
/**
* Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
* /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides
* quand la hiérarchie n'a pas pu être remontée depuis la base FF.
*/
nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(),
search: z.string().min(1).optional(),
/**
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
@@ -52,6 +64,19 @@ export const gridQuerySchema = z.object({
compute: z.enum(["0", "1"]).default("1"),
...sortShape,
...paginationShape,
/**
* **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
* renvoie *toutes* les lignes du fournisseur.
*
* C'est l'objectif de cet endpoint — qu'une app ou un agent récupère un
* fournisseur entier en un appel. Paginer par défaut condamnait l'appelant à
* boucler, ce que les agents font mal : ils s'arrêtent à la première page et
* raisonnent sur un catalogue tronqué.
*
* Réponse volumineuse : une ligne complète porte les séries mensuelles et les
* ventilations par magasin. Combiner avec `fields` est vivement conseillé.
*/
limit: z.coerce.number().int().min(1).optional(),
});
/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */
@@ -69,6 +94,19 @@ export const fournisseursQuerySchema = z.object({
withData: z.enum(["0", "1"]).optional(),
});
/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */
export const nomenclaturesQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
/**
* 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
* Le `transform` ne fait que redonner le type littéral : les bornes ont déjà
* été validées juste avant, la valeur ne peut être que 1, 2 ou 3.
*/
niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3),
/** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */
parent: z.string().min(1).optional(),
});
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
+92 -5
View File
@@ -44,6 +44,8 @@ export interface GridQuery {
code1?: string;
code2?: string;
code3?: string;
/** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */
nomenclature?: string;
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
search?: string;
sort?: GridSortKey;
@@ -153,6 +155,10 @@ function buildWhere(q: GridQuery): SQL | undefined {
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`);
const search = q.search?.trim();
if (search) {
const pattern = `%${search}%`;
@@ -174,7 +180,10 @@ function buildWhere(q: GridQuery): SQL | undefined {
*/
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
const page = Math.max(1, q.page ?? 1);
const limit = Math.min(500, Math.max(1, q.limit ?? 100));
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
// Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que
// soit la valeur demandée — la réponse était tronquée sans que rien ne le dise.
const limit = q.limit != null ? Math.max(1, q.limit) : null;
const where = buildWhere(q);
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
@@ -186,13 +195,15 @@ export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
.from(gridRows)
.where(where);
const found = await db
const selection = db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(where)
.orderBy(orderBy, asc(gridRows.codein))
.limit(limit)
.offset((page - 1) * limit);
.orderBy(orderBy, asc(gridRows.codein));
const found = limit != null
? await selection.limit(limit).offset((page - 1) * limit)
: await selection;
let oldest: Date | null = null;
for (const r of found) {
@@ -272,3 +283,79 @@ export async function listGridSuppliers(): Promise<
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
}));
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */
export interface GridNomenclature {
code: string;
/** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */
libelle: string | null;
nbArticles: number;
totalCa: number;
totalQuantite: number;
}
/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */
export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 };
/**
* Répartition des articles d'un fournisseur par niveau de nomenclature.
*
* Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
* est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
* valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
* annule le bénéfice du découpage.
*
* Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
* chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
* ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
* hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de
* la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le
* préfixe, lui, est toujours disponible.
*
* Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
* un fournisseur de 130 000 articles.
*/
export async function listGridNomenclatures(
codeFournisseur: string,
niveau: 1 | 2 | 3,
parent?: string,
): Promise<GridNomenclature[]> {
const longueur = NOMENCLATURE_LONGUEURS[niveau];
const codeExpr = sql<string>`left(${gridRows.code3}, ${longueur})`;
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
// le code et les compteurs, eux, restent justes.
const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3";
const clauses: SQL[] = [
eq(gridRows.codeFournisseur, codeFournisseur),
sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`,
];
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`);
const rows = await db
.select({
code: codeExpr,
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
nbArticles: sql<number>`count(*)::int`,
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
totalQuantite: sql<number>`coalesce(sum(${gridRows.totalQuantite}), 0)::float`,
})
.from(gridRows)
.where(and(...clauses))
.groupBy(codeExpr)
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
return rows.map((r) => ({
code: String(r.code ?? ""),
libelle: r.libelle ?? null,
nbArticles: Number(r.nbArticles) || 0,
totalCa: Number(r.totalCa) || 0,
totalQuantite: Number(r.totalQuantite) || 0,
}));
}
+225 -39
View File
@@ -72,6 +72,8 @@ export interface PgStockRow {
qte: number;
valstock: number;
prmp: number;
/** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */
pv?: number;
dernierevente?: string;
dernierereception?: string;
}
@@ -314,22 +316,29 @@ export async function pgGetGammesByCodeins(codeins: string[]): Promise<Map<strin
const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier.
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.codein)
TRIM(a.codein::text) AS codein,
g.code AS gamme_code
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
ORDER BY a.codein, s.no_id DESC
`);
// Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL
// en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles —
// la requête échouait alors purement et simplement.
const CHUNK = 2000;
const map = new Map<string, string>();
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
for (let i = 0; i < unique.length; i += CHUNK) {
const batch = unique.slice(i, i + CHUNK);
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier.
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.codein)
TRIM(a.codein::text) AS codein,
g.code AS gamme_code
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) IN (${sql.join(batch.map(c => sql`${c}`), sql`, `)})
ORDER BY a.codein, s.no_id DESC
`);
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
}
}
console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`);
return map;
@@ -441,14 +450,17 @@ function buildNomMap(rows: PgNomRow[]): Map<string, PgNomRow> {
* 1 requête SQL — remplace la partie stock du référentiel.
*/
export async function pgGetStockByFournisseur(codefou: string): Promise<Map<string, PgStockRow[]>> {
// `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur :
// il sert de clé de recherche (derniereLivraisonByStore, prix de repli).
const result = await pgNoParallel(sql`
SELECT
a.codein,
cs.site,
TRIM(cs.site::text) AS site,
cs.stockdispo::float,
cs.qte::float,
cs.valstock::float,
cs.prmp::float,
cs.pv::float,
cs.dernierevente::text,
cs.dernierereception::text
FROM cube_stock cs
@@ -465,6 +477,46 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
return map;
}
/**
* Prix de vente par article et par site (`cube_pv`).
*
* Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube
* MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule
* table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de
* paramétrage (`prix_vente_mini`) qui reste vide en pratique.
*
* Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non
* tarifé n'est pas un article à 0 €.
*/
export async function pgGetPrixVenteByFournisseur(codefou: string): Promise<Map<string, Record<string, number>>> {
// Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où
// l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer
// par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur
// fixe remonterait complétée d'espaces, et la recherche échouerait en silence —
// le prix resterait visible en « tous magasins » et vide magasin par magasin.
const result = await pgNoParallel(sql`
SELECT
a.codein,
TRIM(cpv.site::text) AS site,
cpv.pv::float AS pv
FROM cube_pv cpv
JOIN articles a ON a.no_id = cpv.artnoid
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
ON af.art_no_id = a.no_id
WHERE cpv.pv IS NOT NULL AND cpv.pv > 0
`);
const map = new Map<string, Record<string, number>>();
for (const row of result.rows as { codein: string; site: string; pv: number }[]) {
if (!row.codein || !row.site) continue;
const parSite = map.get(row.codein) ?? {};
parSite[String(row.site)] = Number(row.pv);
map.set(row.codein, parSite);
}
console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`);
return map;
}
// ---------------------------------------------------------------------------
// 6. Commandes en cours
// ---------------------------------------------------------------------------
@@ -978,6 +1030,143 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<strin
return map;
}
// ---------------------------------------------------------------------------
// Stock — fournisseur de la dernière entrée
// ---------------------------------------------------------------------------
/**
* Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur.
* `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le
* détecte une fois par process (même principe que `getNomenclatureParentCol`).
* - target "artfou1" : la colonne est une FK vers `artfou1.no_id`
* - target "code" : la colonne contient directement le code fournisseur (`fouident.code`)
*/
const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [
{ column: "artfou1_no_id", target: "artfou1" },
{ column: "artfou_no_id", target: "artfou1" },
{ column: "afou_no_id", target: "artfou1" },
{ column: "codefou", target: "code" },
{ column: "code_fou", target: "code" },
{ column: "fou_code", target: "code" },
{ column: "fournisseur", target: "code" },
];
let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined;
async function getMvtartFouLink() {
if (_mvtartFouLink !== undefined) return _mvtartFouLink;
try {
const meta = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'mvtart'
`);
const cols = new Set(
(meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase())
);
// Le nom vient d'information_schema ET doit figurer dans la liste blanche
// ci-dessus : aucune valeur utilisateur n'atteint le SQL.
_mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null;
} catch (e) {
console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e);
_mvtartFouLink = null;
}
console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)");
return _mvtartFouLink;
}
/**
* Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du
* couple article/site : elle expose `lastentry.datmvt` (date) et
* `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas
* l'information).
*
* Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière
* entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus
* apparaître sous le précédent.
*
* La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie
* dernière entrée, même si son fournisseur est inconnu.
*/
function lastEntryJoin(
link: { column: string; target: "artfou1" | "code" } | null,
artnoidExpr: SQL,
siteExpr: SQL,
): SQL {
const codeExpr = link
? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`))
: sql`NULL::text`;
const joinArtfou = link && link.target === "artfou1"
? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}`
: sql``;
return sql`
LEFT JOIN LATERAL (
SELECT me.datmvt, ${codeExpr} AS code
FROM mvtart me
${joinArtfou}
WHERE me.artnoid = ${artnoidExpr}
AND me.site = ${siteExpr}
AND me.genremvt IN (1, 2)
ORDER BY me.datmvt DESC, me.no_id DESC
LIMIT 1
) lastentry ON TRUE
${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``}
`;
}
/**
* Libellé fournisseur affiché : celui de la dernière entrée quand la base le
* permet, sinon repli documenté sur `artfou1.preference = 1`.
*/
function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL {
return link
? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)`
: sql`COALESCE(f.nom, af.code)`;
}
/**
* Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les
* colonnes réellement présentes sur `mvtart` et un échantillon comparant le
* fournisseur principal au fournisseur de la dernière entrée.
*/
export async function pgDiagFournisseurDerniereEntree() {
const link = await getMvtartFouLink();
const meta = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'mvtart' ORDER BY ordinal_position
`);
let echantillon: unknown[] = [];
if (link) {
const r = await pgNoParallel(sql`
SELECT
a.codein,
cs.site,
COALESCE(f.nom, af.code) AS fournisseur_preference,
COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree,
lastentry.datmvt::text AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
LIMIT 20
`);
echantillon = r.rows;
}
return {
colonneDetectee: link,
candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column),
colonnesMvtart: meta.rows,
echantillon,
};
}
// ---------------------------------------------------------------------------
// Stock négatif — tous magasins ou par site
// ---------------------------------------------------------------------------
@@ -992,10 +1181,6 @@ export interface PgStockNegatifRow {
derniereentree: string | null;
}
/**
* Retourne tous les articles avec un stock disponible négatif.
* Si `site` est fourni, filtre sur ce magasin uniquement.
*/
export interface PgStockSansVenteRow {
codein: string;
libelle1: string;
@@ -1008,22 +1193,30 @@ export interface PgStockSansVenteRow {
/**
* Retourne les articles ayant des entrées en stock (hors mois courant)
* mais aucune vente enregistrée sur ce site.
* Les articles dont le stock actuel est nul sont exclus (rien à régulariser).
*/
export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND m.site = ${site}` : sql``;
const result = await pgNoParallel(sql`
SELECT
a.codein,
COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur,
${fournisseurExpr(link)} AS fournisseur,
m.site,
COALESCE(cs.qte::float, 0) AS stock_actuel,
MAX(m.datmvt)::text AS derniere_entree
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
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)}
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
@@ -1034,29 +1227,25 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
AND mv2.site = m.site
AND mv2.genremvt = 3
)
GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte
GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte
HAVING COALESCE(cs.qte::float, 0) <> 0
ORDER BY MAX(m.datmvt) DESC
`);
return result.rows as PgStockSansVenteRow[];
}
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
const result = await pgNoParallel(sql`
SELECT
a.codein,
COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur,
${fournisseurExpr(link)} AS fournisseur,
cs.site,
cs.qte::float AS stockdispo,
cs.dernierevente::text,
(
SELECT MAX(m.datmvt)::text
FROM mvtart m
WHERE m.artnoid = cs.artnoid
AND m.site = cs.site
AND m.genremvt IN (1, 2)
) AS derniereentree
lastentry.datmvt::text AS derniereentree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
@@ -1066,6 +1255,7 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
${siteFilter}
@@ -1095,12 +1285,13 @@ export interface PgSansVente6MoisRow {
* ET dont la dernière entrée en stock remonte aussi à plus de 6 mois.
*/
export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6MoisRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
const result = await pgNoParallel(sql`
SELECT
a.codein,
COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur,
${fournisseurExpr(link)} AS fournisseur,
cs.site,
cs.qte::float AS stock_actuel,
cs.dernierevente::text AS derniere_vente,
@@ -1108,13 +1299,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
WHEN cs.dernierevente IS NULL THEN NULL
ELSE (CURRENT_DATE - cs.dernierevente::date)
END AS jours_sans_vente,
(
SELECT MAX(m.datmvt)::text
FROM mvtart m
WHERE m.artnoid = cs.artnoid
AND m.site = cs.site
AND m.genremvt IN (1, 2)
) AS derniere_entree
lastentry.datmvt::text AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
@@ -1124,6 +1309,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.site IN ('292', '579')
AND cs.qte > 0
AND (
+13 -4
View File
@@ -42,10 +42,19 @@ export async function getNetworkMetricsByCodeCentrale(
const unique = [...new Set(codes.filter(Boolean))];
if (unique.length === 0) return out;
const rows = await db
.select()
.from(qlikNetworkMetrics)
.where(inArray(qlikNetworkMetrics.codeCentrale, unique));
// Découpage obligatoire : `inArray` produit un paramètre lié par valeur, or le
// protocole PostgreSQL en accepte 65535 au maximum. Un gros fournisseur peut
// dépasser 130 000 codes — la requête échouait alors purement et simplement.
const CHUNK = 2000;
const rows: Array<typeof qlikNetworkMetrics.$inferSelect> = [];
for (let i = 0; i < unique.length; i += CHUNK) {
const batch = unique.slice(i, i + CHUNK);
const part = await db
.select()
.from(qlikNetworkMetrics)
.where(inArray(qlikNetworkMetrics.codeCentrale, batch));
rows.push(...part);
}
for (const r of rows) {
out.set(r.codeCentrale, {
+54 -16
View File
@@ -2296,24 +2296,62 @@ export async function fetchNetworkMetricsPlaywright(
},
);
// Reprise globale sur erreur transitoire.
//
// Le moteur Qlik abandonne les requêtes longues quand il est chargé
// (« code 15 / Request aborted »). Observé sur un même fournisseur, avec
// le même code : réussite à 4 h du matin (OpenDoc en 78 s), échec à 6 h
// (OpenDoc en 233 s, puis abandon). Les reprises par requête de
// `rpcWithRetry` ne suffisent pas — c'est toute l'extraction qui tombe.
//
// La pause est volontairement longue : relancer aussitôt ne ferait
// qu'ajouter de la charge au serveur qui vient déjà de renoncer.
const extractionEssais = 1 + envNumber("QLIK_EXTRACTION_RETRIES", 1, 0, 3);
const extractionPauseMs = envNumber("QLIK_EXTRACTION_RETRY_PAUSE_MS", 60_000, 0, 600_000);
const estTransitoire = (m: string) => /"code"\s*:\s*15\b/.test(m) || /request aborted/i.test(m);
const pause = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
let result: InPageResult;
try {
result = (await evaluer()) as InPageResult;
} catch (e) {
const msg = String((e as Error)?.message || e);
// Une fenêtre 12 mois n'est publiable que si l'extraction est
// complète et a passé tous les contrôles. Conserver un checkpoint
// partiel fabriquerait précisément des graphiques à 2 ou 3 mois.
if (dateFilter) {
throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`);
}
const secours = checkpoint as { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] } | null;
if (secours?.rows?.length) {
console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`);
result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true };
} else {
throw e;
let essai = 0;
for (;;) {
essai++;
const reste = essai < extractionEssais;
try {
const candidat = (await evaluer()) as InPageResult;
if (candidat.ok || !reste || !estTransitoire(String(candidat.error ?? ""))) {
result = candidat;
break;
}
console.warn(
`[qlik-pw] tentative ${essai}/${extractionEssais} abandonnée par le moteur (${String(candidat.error).slice(0, 160)})`
+ ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`,
);
} catch (e) {
const msg = String((e as Error)?.message || e);
if (reste && estTransitoire(msg)) {
console.warn(
`[qlik-pw] tentative ${essai}/${extractionEssais} interrompue (${msg.slice(0, 160)})`
+ ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`,
);
} else {
// Comportement d'origine, une fois les reprises épuisées.
// Une fenêtre 12 mois n'est publiable que si l'extraction est
// complète et a passé tous les contrôles. Conserver un checkpoint
// partiel fabriquerait précisément des graphiques à 2 ou 3 mois.
if (dateFilter) {
throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`);
}
const secours = checkpoint as { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] } | null;
if (secours?.rows?.length) {
console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`);
result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true };
break;
}
throw e;
}
}
checkpoint = null; // la nouvelle tentative repart d'une extraction vierge
await pause(extractionPauseMs);
}
if (!result.ok) {
+22 -2
View File
@@ -89,7 +89,21 @@ export interface ProductRow {
/** Données stock & approvisionnement (API FF Nancy) */
pcb?: number; // Conditionnement (pack size)
prixAchat?: number; // PA / PRMP
prixVente?: number; // PV central
/**
* Prix de vente pratiqué, en euros.
*
* Source par ordre de préférence : `cube_pv` (prix de vente par article et
* par site, pendant de `cube_pa` pour l'achat), puis le PV porté par
* `cube_stock`, puis `article_infosup.prix_vente_mini` — cette dernière
* n'est qu'une borne de paramétrage, vide en pratique.
*
* Quand les magasins ne pratiquent pas le même prix, cette valeur est la
* plus élevée : `prixVenteByStore` porte le détail, et l'affichage signale
* la divergence plutôt que d'inventer une moyenne que personne n'encaisse.
*/
prixVente?: number;
/** Prix de vente par magasin (site → PV) — `cube_pv`. */
prixVenteByStore?: Record<string, number>;
stockActuel?: number; // Stock dispo agrégé tous sites
stockTotal?: number; // Qte en stock agrégé
stockValeur?: number; // Valeur stock agrégée
@@ -149,7 +163,13 @@ export interface GridFilters {
codeFournisseur: string | null;
code1: string | null;
code2: string | null;
code3: string | null;
/**
* Nomenclatures retenues (codes `code3`).
* `null` = aucun filtre (tout s'affiche) ; `[]` = rien de coché (aucune
* ligne). Distinguer les deux évite la valeur sentinelle qu'imposerait un
* simple tableau.
*/
code3: string[] | null;
codeGamme: GammeCode | null;
search: string;
}