Commit Graph
228 Commits
Author SHA1 Message Date
Claude af5933b5a7 Lot 6 : Grille plus légère, Commandes allégées et nettoyage
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:48:43 +00:00
Claude 26b9364232 Lot 5 : changement de magasin instantané et contrôle des droits
Grille
- Corrige le lot 4 : au changement de magasin, les chiffres et le tri
  affichés suivent tout de suite (nouvelle liste), la sélection est gardée.
- Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans
  aller-retour serveur (URL mise à jour par l'historique du navigateur).
- Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch),
  appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts).
- La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la
  sélection n'est remise à zéro qu'au changement de fournisseur.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:39:49 +00:00
Claude b7ee413d67 Lot 4 : Grille servie depuis l'instantané et requêtes allégées
- Grille : lecture de l'instantané grid_rows (gamme INIT et dernières gammes
  enregistrées reportées), recalcul en arrière-plan au-delà de 20 h, version de
  format dans chaque ligne pour écarter les anciens instantanés.
- Navigateur : lignes réutilisées au retour sur la Grille (< 10 min), Grille
  conservée pendant un changement de magasin ou une actualisation, React.memo
  sur la Grille, les filtres et la barre du bas, en-tête mémorisé.
- Fiche produit : toutes les lectures en parallèle.
- Ventes par mois et stock sans vente : plages de dates au lieu de TO_CHAR.
- Accueil : vagues de 12 appels, cache de 30 min.
- API v1 : comptage et page en parallèle, last_used_at au plus une fois par
  minute, version interne non exposée.
- Plan : suivi du lot 4, avec ce qui reste à valider sur la base réelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 21:15:33 +00:00
Claude 220ca2263a feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair
Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 21:05:46 +00:00
Claude 7da4c6d5fd perf: lot 1 — chargements plus rapides et corrections, sans changement visuel
Grille (navigateur)
- « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait
  mémorisé et chaque retour par le menu forçait un recalcul serveur complet
  (migration du store pour nettoyer les valeurs déjà enregistrées).
- Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le
  tableau à chaque paquet de 150 (chargement quadratique).
- Sélection indexée par code article (getRowId) : après un filtre, l'action
  groupée visait d'autres produits. Seules les lignes affichées comptent.
- Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de
  nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ;
  plus de transition-all sur les lignes positionnées par transform.
- exceljs, jspdf et xlsx chargés au clic seulement.

Grille (serveur)
- Requêtes Qlik et snapshot lancées en parallèle de la phase SQL.
- Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ;
  flux interrompu quand le client part.
- Après un enregistrement, les gammes sont reportées dans le cache et dans
  grid_rows au lieu d'invalider (recalcul de ~40 s évité).

Données et API
- lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade,
  CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de
  synchro nocturne ; erreurs jamais gardées.
- Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé
  une fois par connexion au lieu d'une transaction autour de chaque requête.
- Délai maximal sur tous les appels à l'API FF.
- Index session_snapshots ; commandes : plus de double rechargement ;
  synchro : saisies regroupées ; paramètres : configuration lue une fois ;
  journal de capture en ajout seul, rien de formaté sans capture ouverte.

Corrections
- Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »).
- Sessions et validations enregistrent le magasin affiché, plus « TOTAL ».
- Publicités : libellé du statut « passées ».

Plan complet : docs/plan-optimisation-webui.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 20:28:14 +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
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
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 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
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
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 3fa92e891b merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:13:44 +00:00
Claude c565672259 feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on
demande à Qlik les métriques de ces codes. Un produit vendu par le réseau
mais absent de FF Nancy était donc invisible par construction.

Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par
code centrale, et affiche aussi les produits que nous ne référençons pas.

- searchNetworkProductsPlaywright() : sélection par SelectValues sur
  « Article Code » (mode code) ou par SearchListObjectFor +
  AcceptListObjectSearch sur la dimension « Article » (mode plein texte,
  avec repli sur des noms de champs si la master dimension échoue).
  Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour
  obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche.
- /api/qlik/search : job asynchrone (POST + polling GET) comme la sync,
  une seule recherche à la fois pour ménager le moteur Qlik. Met les
  résultats en cache dans qlik_network_metrics et enrichit avec le
  catalogue local (badge « Chez moi » / « Réseau uniquement », stock).
- Page /recherche-reseau : tableau triable, filtres, fiche produit avec la
  courbe 12 mois, export Excel.
- Composants de tendance extraits de heatmap-grid vers features/network
  pour être partagés (comportement inchangé).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:22:53 +00:00
Claude e79d7e51da feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques
réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et
getProductRows() exige un fournisseur : chercher un produit sans le connaître
était impossible.

Contrainte de conception : l'API ne recalcule jamais rien. La grille était
reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui
appellerait getProductRows() serait lente et imprévisible. On persiste donc
le résultat d'un calcul qui a déjà lieu, et on le sert.

- Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) +
  payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant
  par getProductRows(), purge des articles disparus via computed_at. Survit
  aux redémarrages, contrairement au cache mémoire.
- Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous
  fournisseurs), products/:codein, network/:codeCentrale, openapi.json.
  Pagination, tri sur liste blanche, projection de champs, validation zod.
  202 not_ready si un fournisseur n'a pas encore d'instantané.
- Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base,
  révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi
  un script recevait une redirection 302 vers /login au lieu d'un 401 JSON.
- Gestion des clés dans /settings (server actions, clé affichée une seule fois).

Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé
révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et
24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route
/api/grid/rows en déclenche un à chaque appel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
Claude 150baeef9a Carte tendance : hauteur figee, bascule Graphique/Tableau, cellules copiables
1. LA CARTE CHANGEAIT DE TAILLE PENDANT LE SURVOL.
   La lecture du mois vivait sous le graphique, en HTML : selon la longueur
   des valeurs elle passait sur deux lignes et la carte sautait. Elle est
   desormais dessinee DANS le SVG, dont la geometrie est figee par le
   viewBox — infobulle ancree au reticule, qui bascule a gauche quand elle
   deborderait a droite.

   Deux causes residuelles trouvees a la mesure, pas a la lecture :
   - le SVG etait dimensionne en pourcentage, donc sa hauteur dependait de
     la largeur : il porte maintenant une hauteur en pixels ;
   - un SVG en ligne traine l'espace sous la ligne de base (6 px mesures),
     ce qui decalait la vue tableau : `display:block`.

   Verifie en pilotant le composant reellement monte : 382,5 px au repos,
   sur quatre positions de survol et dans les deux vues, en clair comme en
   sombre.

2. LES VALEURS MOIS PAR MOIS SANS AGRANDIR LA CARTE.
   Le deplaint aurait fait grandir la carte a l'ouverture — exactement ce
   qu'on venait de corriger. Le tableau occupe donc la place du graphique
   dans un panneau de MEME HAUTEUR et defile a l'interieur.

3. REFERENCE ET EAN COPIABLES, comme le code interne.
   La copie etait ecrite en ligne dans la colonne « Code interne », avec un
   `eslint-disable react-hooks/rules-of-hooks` : un useState dans le corps
   d'un cell() de TanStack n'est pas rendu au meme endroit d'un rendu a
   l'autre. Extraite en composant, la derogation disparait et les trois
   colonnes partagent le meme comportement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 10:40:37 +00:00
Claude 5ad222431d Carte tendance : refonte du graphique selon les regles data-viz
Petits multiples empiles sur un axe des mois commun, palette validee par
script, etiquetage selectif, survol et vue tableau.

FORME. Trois mesures d'ordres de grandeur differents (qte/magasin,
volumes, magasins) ne peuvent pas partager un cadre : il faudrait deux ou
trois echelles verticales dont l'alignement serait arbitraire, ce qui
inventerait des correlations absentes des donnees. Trois facettes, une
echelle chacune, le meme axe horizontal. La facette PRINCIPALE est la
qte/magasin.

COULEUR. Slots 1-3 de la palette categorielle de reference, en variables
CSS, avec un jeu de pas CHOISI pour le fond sombre (pas un eclaircissement
automatique). Palette passee au validateur dans les deux modes, toutes
paires : bande de clarte, plancher de chroma, separation daltonienne
(pire paire ΔE 9,2 clair / 9,4 sombre) et contraste. En mode clair l'aqua
tombe a 2,82:1 — la regle de relief s'applique, d'ou etiquettes visibles
ET vue tableau, toutes deux presentes.

La teinte de tendance (vert/rouge/gris) ne vit plus que dans la tuile de
verdict, avec fleche et libelle : un etat ne se signale jamais par la
couleur seule, et la meme couleur ne veut pas dire deux choses.

LISIBILITE. Etiquetage selectif — l'extreme et le dernier mois, pas les 36
valeurs. Le texte ne porte jamais la couleur d'une serie (illisible en
teinte claire) : l'identite vient du repere pose a cote. Filets de base
pleins et discrets, jamais pointilles. Reticule au survol reliant les
trois facettes au meme mois, et vue tableau depliable : aucune valeur
n'est accessible au seul survol.

En-tete refait en tuiles : verdict, qte/magasin du dernier mois, magasins
vendeurs. Chiffres proportionnels sur les valeurs mises en avant,
tabulaires seulement dans le tableau ou les colonnes s'alignent.

Rendu verifie en image dans les deux themes avant livraison : la version
precedente faisait passer l'etiquette du pic par-dessus le titre de la
facette — elle bascule desormais sous le point quand elle toucherait le
bandeau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:58:31 +00:00
Claude eaac945345 Tendance : la calculer sur la QTE PAR MAGASIN, pas sur le volume brut
C'est le bon indicateur, et le volume brut ne pouvait pas le remplacer :
il confond la performance du produit et sa diffusion. Un produit monte en
volume des qu'on le reference ailleurs, sans mieux se vendre nulle part ;
un produit retire de 60 magasins s'effondre en volume tout en performant
mieux la ou il reste. Seule la qte/magasin repond a « ce produit est-il
tendance ».

- computeNetworkTrend prend le nombre de magasins par mois et calcule
  l'evolution sur qte/magasins vendeurs. Sans cette serie, repli sur le
  volume brut et `surQteParMagasin` a false, pour que l'UI le dise.
- La serie remonte jusqu'a tous les appelants : grille, fiche produit et
  recherche produits (nbMagByMonth ajoute au resultat de recherche).
- La carte affiche desormais trois bandes empilees sur le meme axe :
  qte/magasin en PRINCIPALE (couleur de la tendance, aire pleine),
  quantites vendues et magasins vendeurs en contexte. La teinte
  « tendance » ne designe plus qu'une seule chose dans le graphique ; les
  bandes de contexte ont des couleurs fixes.
- Le pourcentage de la carte precise « (qte/magasin) » : un chiffre de
  tendance sans son assiette se prete a tous les malentendus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:50:41 +00:00
Claude e6e79dfe06 Carte tendance : desencombrer le graphique, il etait illisible
La densite etait le vrai probleme. La version precedente imprimait les 24
valeurs (12 quantites + 12 magasins) et titrait chaque bande DANS le SVG.
Resultat sur ecran : les titres passaient par-dessus les courbes, les
nombres des extremites etaient coupes par le bord du cadre, et les
etiquettes des magasins se collaient au trait pointille.

- Les titres sortent du dessin : legende HTML au-dessus, plus aucun
  recouvrement possible quelle que soit la forme des courbes.
- La bande des magasins ne porte plus que sa DERNIERE valeur ; son niveau
  se lit a la forme et au maximum rappele dans la legende.
- Les etiquettes des bords sont ancrees vers l'interieur (start / end au
  lieu de middle) : plus rien n'est tronque.
- Chaque mois expose une infobulle native avec les valeurs exactes, ce qui
  rend inutile d'imprimer tous les nombres.
- Cadre elargi (520 de large, marge haute pour les etiquettes du sommet) et
  modale passee en max-w-xl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:46:15 +00:00
Claude bf6fef6a14 Carte tendance : deux bandes separees, indicateur lisible, courbe magasins reparee
Trois defauts signales, trois causes distinctes.

1. LA COURBE DES MAGASINS N'APPARAISSAIT PRESQUE JAMAIS.
   L'extracteur ecrivait `{ qte: 0 }` sur les mois sans vente, sans
   `nbMag`. La serie exigeant les 12 mois, elle disparaissait des qu'un
   produit avait un mois creux — c'est-a-dire presque toujours. Un mois
   PRESENT dans le detail est un mois extrait : s'il n'a pas de nbMag,
   c'est que personne n'a vendu, donc zero. Corrige a la source ET a la
   lecture, pour que les caches deja ecrits repartent sans re-sync.

2. LES DEUX COURBES ETAIENT ILLISIBLES SUPERPOSEES.
   Deux echelles independantes placaient la courbe des magasins AU-DESSUS
   de celle des quantites : cela se lit spontanement comme « il y a plus
   de magasins que de ventes », un contresens. Les series sont desormais
   tracees dans DEUX BANDES distinctes alignees sur le meme axe des mois.
   On compare la forme de l'une avec la forme de l'autre, sans jamais
   suggerer un rapport de grandeur.

3. LES POURCENTAGES N'AVAIENT PAS DE SENS.
   La pente d'une regression rapportee a la moyenne donnait « -124 % »
   (une baisse ne peut pas depasser -100 %), « +1 062 % » pour 2 unites
   vendues une fois dans l'annee, et surtout « +508 % » sur des dizaines
   de lignes d'affilee — la signature arithmetique d'une serie nulle
   partout sauf le dernier mois, ou le rapport ne depend meme plus des
   quantites. On compare maintenant deux moyennes de 4 mois : variation
   qui se lit comme telle, plancher a -100 %, insensible au bruit d'un
   mois isole. Sans base de comparaison, aucun pourcentage n'est
   invente — le produit est marque « Nouveau », et le tri le remonte en
   tete au lieu de l'enterrer.

Le graphique accepte aussi des quantites negatives (retours superieurs
aux ventes sur un mois) : l'echelle part du minimum reel, le point ne
sort plus du cadre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:41:25 +00:00
Claude 28fe78a646 Carte tendance : garantir que la courbe magasins se distingue des quantites
La courbe des quantites prend la couleur de la TENDANCE : vert, rouge, ou
gris-bleu (#94a3b8) quand elle est stable. L'indigo des magasins se
detache du vert et du rouge, mais il est trop proche du gris-bleu — or
« stable » est le cas le plus frequent.

storesColorFor() bascule sur l'ambre (#f59e0b) dans ce cas precis : gris
froid contre orange chaud, impossible a confondre.

Les deux series se distinguent desormais par TROIS canaux, pas seulement
la couleur — qui ne suffit ni en impression noir et blanc, ni pour un
daltonien :
- teinte garantie differente,
- trait pointille contre trait plein,
- points evides contre points pleins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:10:31 +00:00
Claude abc704a363 Carte tendance : deuxieme courbe du nombre de magasins vendeurs
Une quantite qui monte parce que le produit est diffuse dans plus de
magasins ne raconte pas la meme histoire qu'une quantite qui monte a
diffusion constante. La courbe des quantites seule ne permettait pas de
distinguer un succes produit d'un simple elargissement.

- computeStoresSeries() : serie « magasins vendeurs » sur la MEME fenetre
  12 mois que la tendance, avec la meme regle du tout ou rien — les 12
  cles doivent etre presentes, sinon null. Un mois non extrait ne doit
  pas se lire comme « zero magasin », ce qui simulerait un arret de
  diffusion.
- nbMagReseauByMonth remonte du cache (metricsByMonth, deja stocke par la
  sync) jusqu'a la ligne de grille.
- NetworkLineChart accepte une serie `stores` optionnelle.

DEUX ECHELLES INDEPENDANTES : les quantites se comptent en milliers, les
magasins plafonnent a ~270. Sur une echelle commune la courbe des
magasins serait ecrasee sur l'axe et ne montrerait rien. Chaque serie est
normalisee sur son propre maximum, rappele dans la legende — c'est la
FORME des deux courbes qu'on compare, pas leurs hauteurs. La courbe des
magasins est pointillee et indigo, pour ne jamais entrer en collision
avec le vert/rouge/gris de la tendance.

Branchee sur la carte de la Grille et sur celle de la fiche produit. La
sparkline de la colonne reste a une seule courbe : 46x18 px, deux traces
y seraient illisibles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:07:39 +00:00
Claude 5f3760f03e merge: piloter Période + garde-fou d'intégrité + sélection par dimension Mois
Fusion de deux lignes de travail parallèles sur main, complémentaires et non
concurrentes :

  cd2e77e  selectionnerFenetreViaDimensionMois() — sélectionne les 12 mois par
           qElemNumber sur la dimension maître Mois. C'est ce qui marche : les
           champs Date exposés sont dissociés des faits (100 % du total avant
           comme après), la dimension maître porte les vraies valeurs.
  4ef0e9f  garde-fou d'intégrité — refuse d'écrire le cache si la fenêtre n'est
           pas entièrement couverte. C'est pourquoi le cache est resté inchangé
           au lieu d'être corrompu.
  726fb84  choisirPeriode() — le maillon manquant.

Le dump des expressions donne la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)

Un pont de périodes duplique les lignes de faits ; Type_Cal marque la période
analysée (N) ou de comparaison (COMP), et le champ Période (4 valeurs) décide
de quelle période il s'agit. Sélectionner correctement les mois ne suffit donc
pas : sous la période par défaut, « Quantité N » rend 0 sur tout mois hors
période courante, même parfaitement sélectionné.

Orchestration fusionnée :
  1. choisirPeriode() essaie chaque valeur de Période et MESURE, sur un cube
     [Mois] × Quantité N, combien de mois de la fenêtre elle rend disponibles.
     Aucun libellé n'est deviné.
  2. Si une période couvre les 12 mois → master measures (exactes sur toute la
     fenêtre). Sinon → agrégation directe des faits, en secours.
  3. Dans les deux cas, la couverture des 12 mois est revérifiée avant
     persistance ; sinon l'extraction est refusée et le cache laissé intact.

La tendance conserve la règle stricte de 4ef0e9f (les 12 clés explicitement
présentes, ou pas de tendance), qui écarte aussi le « +4100 % » calculé sur deux
points pour un article vendu depuis mai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 11:27:51 +00:00
Claude 726fb8418d fix(qlik): piloter le champ Période du modèle au lieu de lutter contre lui
Le dump des expressions donne enfin la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)
  « Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)

L'app n'est pas un modèle « faits + calendrier ». Un pont de périodes duplique
les lignes de faits : Type_Cal='N' marque celles de la période analysée, 'COMP'
celles de la période de comparaison, et le champ Période (4 valeurs, une
sélectionnée) décide de quelle période il s'agit. Tout en découle :

  - sélectionner Date ne change rien (mesuré : 100 % du total avant comme après),
    le périmètre venant du pont de périodes et non du champ date ;
  - « Quantité N » ne couvre que la période courante, d'où août→décembre vides ;
  - « Quantité COMP » ne couvre que les mois ayant un comparable, d'où
    janvier→juillet de l'année précédente ;
  - Sum(quantite) brut additionne les lignes N ET COMP : il double compte, il ne
    pouvait pas servir de substitut.

choisirPeriode() essaie donc chaque valeur de Période, mesure sur un cube
[Mois] × Quantité N combien de mois de la fenêtre elle rend réellement
disponibles, et garde la meilleure. Aucun libellé n'est deviné : c'est la
couverture mesurée qui décide, et elle est tracée. Si aucune valeur ne couvre
les 12 mois, les mois manquants restent absents du cache.

Aussi :
- Les mois réellement couverts reçoivent un zéro EXPLICITE quand l'article n'a
  pas vendu ; la tendance ne retient que les mois présents comme clés. Un article
  vendu depuis mai voyait sa tendance calculée sur deux points (« +4100 % »).
- Les diagnostics du chemin d'extraction sont répétés en fin de sync
  ([qlik-pw][diag]) : ils étaient au début d'un log de plusieurs milliers de
  lignes.
- QLIK_USE_EXPR passe à désactivé par défaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 11:22:56 +00:00
Michael 4ef0e9fdeb fix Qlik rolling 12-month integrity 2026-07-31 10:28:29 +02:00
Claude b2149ad0a6 fix(ui): message clair quand l'onglet est périmé après un déploiement
« Server Action "…" was not found on the server » n'est pas une panne du
snapshot : Next.js identifie chaque Server Action par un hash calculé à la
compilation, et un onglet ouvert avant un nouveau build continue d'envoyer les
anciens identifiants. La seule issue est de recharger la page.

- src/lib/stale-action.ts : détection de cette erreur + message utilisateur.
- Grille et Snapshots : au lieu d'afficher l'erreur technique brute, on propose
  « Recharger la page ». Les brouillons de la Grille sont persistés
  (localStorage), recharger ne perd rien.
- SuccessModal accepte `variant="error"` : icône d'alerte rouge au lieu de la
  coche verte, et surtout plus de fermeture automatique au bout de 3 s — un
  message d'échec doit rester lisible. Les erreurs s'affichaient jusqu'ici sous
  une coche de succès.
- SuccessModal accepte une action secondaire optionnelle, et joue son animation
  d'entrée en CSS au lieu d'un setState synchrone dans un effet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 12:40:16 +00:00
Claude 203c96f6c9 feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants
Recherche produit
- La page /produits interroge désormais Qlik Sense EN PREMIER
  (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article
  (code + libellé détectés dynamiquement via FieldList), sélection des valeurs
  trouvées, puis liste des Article Code correspondants.
- Les métriques réseau des articles trouvés sont extraites sur 12 mois
  glissants et mises en cache (qlik_network_metrics), puis rapprochées du
  catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale).
- Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé
  dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée.
- Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est
  chargée côté client avec état de chargement, l'extraction Qlik prenant
  plusieurs secondes.

Informations affichées
- Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par
  magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance,
  fournisseur, et présence ou non au catalogue Nancy.
- Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs
  + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %),
  puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois.
- La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau
  travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik
  sont persistés dans le cache réseau pour ce cas.

Tendance réseau
- computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois
  complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois
  absents du cache valent 0 ; ceux antérieurs à la première extraction réelle
  sont écartés au lieu d'être inventés à 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 05:04:34 +00:00
Claude eeaae09282 feat(produits): page de recherche produit + fiche 360° local/réseau
Nouvelle page /produits : recherche par libellé ou par code centrale, puis
fiche complète d'un produit — identité, prix, stock par magasin, ventes sur
12 mois glissants, et l'intégralité des métriques réseau Qlik.

Jusqu'ici les données produit n'étaient accessibles que par fournisseur
(la Grille) ou par classement (Hit Parade) : aucune route n'acceptait un
codein. Les données réseau Qlik n'étaient visibles que dans six colonnes
de la grille d'arbitrage.

Deux analyses ajoutées :
- comparatif local vs réseau, ramené au magasin moyen (indice de
  performance sur les quantités, seule base commune fiable — le CA local
  est TTC et la base du CA Qlik n'est pas documentée, donc indicatif) ;
- opportunités de la même famille : références que le réseau vend bien et
  que nous vendons peu, écart calculé par magasin.

Détail mensuel réseau enrichi : le cube Qlik renvoyait déjà CA, nb de
magasins, CA/magasin et marge % par (article, mois) mais seule la quantité
était conservée. Ces mesures sont désormais stockées dans une nouvelle
colonne metrics_by_month, sans requête Qlik supplémentaire. qte_by_month
reste inchangée — la colonne « Tendance / Réseau » de la Grille en dépend.

L'extraction Qlik peut désormais cibler un seul code centrale
(/api/qlik/sync?codeCentrale=…), ouverte à tout utilisateur connecté et
plafonnée à 3 extractions simultanées ; le mode fournisseur reste
réservé aux admins.

Refactor sans changement de comportement : les helpers de mois, le calcul
de tendance et les graphes SVG quittent heatmap-grid.tsx pour des modules
partagés, et le polling des jobs Qlik devient le hook useQlikSyncJob,
réutilisé par la Grille et par la fiche.

Les calculs de ventes reprennent le SQL canonique de la Grille (genremvt=3
avec quantités niées pour déduire les retours, stock de fin de mois pris
sur le dernier mouvement, report sur les mois sans mouvement), afin que
les chiffres des deux pages soient identiques par construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
2026-07-27 14:26:08 +00:00
Claude 99097b6eca fix(qlik): dérive les mois N-1 depuis Quantité COMP (vrais 12 mois)
Le cube [Article Code, Mois] ne renvoie que les mois de l'année N (les
mesures 'N' sont nulles ailleurs → lignes supprimées). Le code cherchait
des lignes d'année N-1 qui n'existent pas, donc 'Quantité COMP' n'était
jamais exploitée → seulement ~7 mois affichés.

'Quantité COMP' étant le comparable N-1, à la ligne Mois=YYYYMM elle donne
la quantité du MÊME mois l'année précédente. On dérive donc deux points
par ligne : YYYY-MM (Quantité N) et (YYYY-1)-MM (Quantité COMP) → 12 mois
sans ligne supplémentaire ni requête en plus.

UI : l'en-tête du modal affiche le nombre RÉEL de mois disponibles au lieu
de '12 derniers mois' en dur.
2026-07-25 21:31:15 +00:00
Claude 2af7c42b2b fix(grille): tooltip tendance (régression 12 mois) + lots Qlik plus petits (OOM)
- Corrige le tooltip erroné 'X% (4 derniers mois vs 4 précédents)' →
  'Tendance X% sur 12 mois (régression)'. Le calcul est déjà une
  régression linéaire sur 12 mois ; seul le texte était resté l'ancien.
- Réduit la taille des lots du cube [Article Code, Mois] (800→300, replis
  150/75) pour alléger la mémoire moteur Qlik et limiter les 'Out of
  memory / File corrupted' (3002) observés sur le sync.
2026-07-24 07:41:41 +00:00
Claude 5b5e69eed1 feat(grille): modal en courbe unique + retrait Quantité COMP (2025)
- Modal : les 12 barres remplacées par UNE seule courbe SVG des ventes
  réseau mensuelles (points + quantités affichées + axe des mois).
- Sync : retrait de la mesure 'Quantité COMP' (année N-1) — trop lent à
  récupérer. Retour au cube [Article Code, Mois] à 5 mesures (Quantité N).
  Le détail mensuel couvre donc les mois de l'année en cours ; la tendance
  (régression) se calcule sur ces mois.
2026-07-23 20:40:24 +00:00
Claude 6d349e33b4 feat(grille): modal sur colonne Tendance + tendance par régression 12 mois + Quantité COMP
UI:
- La colonne Tendance (renommée 'Réseau · 12 m') ouvre le modal au clic
  (au lieu de la colonne Nb magasins, remise en simple affichage).
- Tendance calculée par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12
  derniers mois : robuste au bruit, % = pente×durée/moyenne. Libellé
  Forte hausse/Hausse/Stable/Baisse/Forte baisse (seuils ±8% / ±25%).

Sync:
- Ajout de la mesure 'Quantité COMP' (année N-1) au cube [Article Code,
  Mois] → 12 mois glissants : mois de l'année N via Quantité N, mois de
  l'année N-1 via Quantité COMP. Cube 8 colonnes, PAGEM 1200 (<10000
  cellules). Total réseau reste en année N (non gonflé).
  Configurable : QLIK_MEAS_QTE_COMP_ID.
2026-07-23 19:53:20 +00:00
Claude 71563500a0 feat(grille): tendance réseau (sparkline) + modal ventes réseau par mois
Le sync Qlik calculait déjà les ventes réseau mois par mois mais les
sommait en un total. On conserve désormais le détail mensuel.

- Sync : accumulation additive qteByMonth { code: { 'YYYY-MM': qté } }
  in-page (chemins rapide + par lots), rattachée au métrique réseau.
  Reset propre en cas de repli code 15. Total inchangé.
- Stockage : colonne jsonb qte_by_month sur qlik_network_metrics
  (schema + db-init ALTER), upsert + lecture.
- ProductRow.qteReseauByMonth propagé jusqu'à la grille.
- UI grille :
  · colonne '% Présence réseau' remplacée par 'Tendance réseau' :
    sparkline 12 mois teintée + flèche + variation % (moyenne 4 derniers
    mois vs 4 précédents), triable ;
  · cellule 'Nb magasins' rendue cliquable → modal des ventes réseau
    mois par mois (barres, 12 derniers mois).

Nécessite un nouveau Sync Qlik pour peupler le détail mensuel (les
données déjà en cache n'ont que le total → modal 'pas encore de détail').
2026-07-22 14:17:57 +00:00
Claude 2e25bbc5b3 feat(grille): bouton "Rafraîchir" pour forcer le rechargement serveur
Ajoute un bouton dans l'en-tête de la Grille qui force le rechargement
des données depuis le serveur en ignorant le cache mémoire de 10 min
(via le paramètre URL _refresh → refresh=1 → forceRefresh). Utile pour
remonter immédiatement l'état serveur courant, notamment la colonne INIT.
Spinner pendant le chargement, désactivé si un chargement est en cours.
2026-07-21 21:11:28 +00:00
Claude f0fdab465c fix(grille): colonne INIT rechargée à chaque chargement (même sur cache)
La colonne Gamme conserve la valeur du snapshot (comportement voulu). Mais
la colonne INIT ne se mettait pas à jour : getProductRows sert les lignes
depuis un cache mémoire de 10 min, et le client n'envoie 'refresh=1' que
sur le bouton de rafraîchissement manuel. Sur une navigation normale dans
les 10 min, codeGammeInit restait donc figé.

Correctif : sur un hit de cache, on re-requête la gamme serveur courante
(pgGetGammesByFournisseur, 1 requête légère) et on met à jour uniquement
codeGammeInit sur les lignes cachées. codeGamme (snapshot) n'est pas
touché ; les données lourdes (ventes/stock/réseau) restent cachées.

Résultat : la colonne INIT reflète toujours l'état serveur à chaque
chargement, et le marqueur 'modifié' (Gamme ≠ INIT) se résorbe dès que
le serveur rattrape la modification. La révision précédente (reconcilier
codeGamme) est annulée : la colonne Gamme reste le snapshot tel quel.
2026-07-21 15:05:02 +00:00
Claude e9ac5bc0a5 fix(grille): la grille reflète l'état serveur courant des gammes (INIT rechargé)
La grille rechargeait bien le gamme INIT depuis l'état live du serveur à
chaque chargement (Phase 6), mais la Phase 9 réappliquait ensuite
AVEUGLÉMENT le 'after' du dernier snapshot sur codeGamme — même quand la
modification avait déjà été appliquée sur le serveur (ou que la gamme
serveur avait évolué depuis). Résultat : la grille affichait l'ancienne
cible du snapshot au lieu de l'état serveur réel, et une modif 'en
attente' fantôme.

Désormais on ne réapplique une modif du snapshot QUE si elle est encore
réellement en attente côté serveur (change.before === gamme live). Sinon
on garde l'état serveur courant. La grille montre donc toujours où en
sont les gammes sur le serveur, et une modif disparaît du 'en attente'
dès qu'elle est appliquée côté serveur — le suivi reste juste.

Note : les brouillons locaux (localStorage) restent gérés côté client
comme du travail non sauvegardé.
2026-07-21 15:00:40 +00:00
Claude b7de347a99 fix(grid,hit-parade): convention de signe nette + lint React Compiler
- Grille (fallback API mensuel) : Math.abs remplacé par la négation sur
  qte_vendue/ca_ht (négatifs côté API) — les retours clients restent
  déduits au lieu d'être inversés en positif, même convention que le SQL.
- Hit Parade (client) : ColHeader extrait hors du composant (erreur
  'Cannot create components during render'), useMemo déplacés avant
  exportToExcel pour préserver la mémoïsation React Compiler, variable
  idx inutilisée supprimée. ESLint 0 erreur, build OK.
2026-07-06 20:28:03 +00:00
R0m1k3 32f9a27563 Fix Qlik network column sorting 2026-06-22 12:12:48 +00:00
R0m1k3 850852912f fix(qlik): track async sync job status 2026-06-21 19:08:04 +00:00
MichaelandClaude Opus 4.8 de8a435c14 fix(qlik): batch SelectValues to avoid Engine abort + drop couv stock metric
Large supplier (4816 codes) triggered Qlik Engine "Request aborted"
(code 15): a single SelectValues over all codes forced the hypercube to
calculate too many rows at once. Now select in 1200-code batches and
accumulate, keeping each calc small. 1417 codes worked before, 4816 did
not - chunking scales to any supplier.

Also remove the "Couverture de stock" network column end-to-end (grid,
types, Qlik clients, cache, schema, db-init), shifting the marge measure
index from 6 to 5 and the hypercube width from 7 to 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:55:03 +02:00