From 7da4c6d5fd4061e662828b15391ad5d10d4b5f7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:28:14 +0000 Subject: [PATCH 1/4] =?UTF-8?q?perf:=20lot=201=20=E2=80=94=20chargements?= =?UTF-8?q?=20plus=20rapides=20et=20corrections,=20sans=20changement=20vis?= =?UTF-8?q?uel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn --- docs/plan-optimisation-webui.md | 272 ++++++++++++++++++ scripts/db-init.js | 9 + .../admin/synchronisation/client.tsx | 32 ++- src/app/(dashboard)/analytics/page.tsx | 5 +- .../commandes-auto/cadencier-client.tsx | 5 +- src/app/(dashboard)/commandes-auto/page.tsx | 9 +- src/app/(dashboard)/hit-parade/client.tsx | 5 +- src/app/(dashboard)/hit-parade/page.tsx | 3 +- src/app/(dashboard)/publicites/client.tsx | 2 + src/app/(dashboard)/publicites/page.tsx | 45 ++- src/app/(dashboard)/settings/page.tsx | 30 +- src/app/(dashboard)/stock-negatif/page.tsx | 9 +- src/app/api/grid/rows/route.ts | 22 +- src/db/index.ts | 47 ++- src/db/schema.ts | 6 +- src/features/admin/api/sync-scheduler.ts | 4 + src/features/commandes-auto/actions.ts | 6 +- src/features/grid/actions.ts | 6 +- src/features/grid/api/get-product-rows.ts | 203 +++++++++---- src/features/grid/api/save-draft-changes.ts | 11 +- .../grid/components/export-dropdown.tsx | 21 +- .../grid/components/floating-summary-bar.tsx | 13 +- src/features/grid/components/grid-client.tsx | 74 +++-- .../grid/components/grid-filter-bar.tsx | 5 +- src/features/grid/components/heatmap-grid.tsx | 83 +++--- .../grid/components/sync-qlik-button.tsx | 18 +- src/features/grid/store/use-grid-store.ts | 29 +- src/features/snapshots/api/get-snapshots.ts | 4 +- .../snapshots/components/snapshot-list.tsx | 15 + src/lib/api-ff-client.ts | 25 +- src/lib/ff-cache.ts | 111 +++++++ src/lib/format.ts | 30 ++ src/lib/grid-store.ts | 33 ++- src/lib/log-capture.ts | 22 +- src/lib/pg-ff-client.ts | 36 +-- 35 files changed, 1010 insertions(+), 240 deletions(-) create mode 100644 docs/plan-optimisation-webui.md create mode 100644 src/lib/ff-cache.ts create mode 100644 src/lib/format.ts diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md new file mode 100644 index 0000000..2719fca --- /dev/null +++ b/docs/plan-optimisation-webui.md @@ -0,0 +1,272 @@ +# Plan — CollectFlow : interface simple, lisible et rapide + +## Contexte + +Analyse des 13 pages (+ layout, menu, en-tête, API, accès BDD). Constats principaux : + +- **Lisibilité** : plus de 160 textes entre 8,5 et 11 px. Libellés en MAJUSCULES grasses gris pâle, sous le seuil de contraste AA. Jargon partout (Init., PCB, PRMP, Franco, Sync Qlik, CODEIN…). Libellés mi-anglais, mi-français (Dashboard, Analytics, Snapshots). +- **Incohérence** : + - **Thème** : 5 pages sont figées en blanc (`bg-gray-50`) dans une application sombre. Les classes `dark:` suivent le réglage de l'OS, pas le bouton de thème. + - **Composants** : 7 styles d'en-tête de page, 13 tableaux codés à la main, formateurs de nombres copiés dans 12 fichiers. + - **Vocabulaire** : magasins nommés de 8 façons. « Réseau » désigne tantôt les ~270 magasins, tantôt nos 2. +- **Ergonomie** : + - La recherche de l'en-tête ne filtre que la Grille, même depuis les autres pages. + - L'utilisateur « Michael » est codé en dur dans l'en-tête. + - La Grille affiche deux boutons « Valider » et deux exports, avec des droits différents. + - Chaque chargement de page bloque tout l'écran (modale plein écran), et aucune page d'erreur n'existe. +- **Lenteur** : + - Hors tableau de bord, aucune donnée n'est mise en cache, alors que les données FF ne changent qu'une fois par nuit. + - Une Grille froide se reconstruit en 10 à 43 s. Ce calcul a lieu après chaque enregistrement, après chaque changement de magasin, et à chaque retour par le menu après un clic sur « Rafraîchir » : le paramètre `_refresh` reste collé à l'URL. + - La Grille rend tout le tableau à chaque paquet de 150 lignes, donc un coût quadratique. + - `exceljs` et `jspdf` sont chargés pour tous les utilisateurs. + - Plusieurs requêtes SQL ne peuvent pas utiliser d'index : `TRIM(codein)`, `TO_CHAR(datmvt)`. +- **Bugs relevés** : + - Après un filtre, la sélection multiple de la Grille vise d'autres produits. + - Le filtre « passées » de Publicités ne s'applique pas (clé `passees` dans le filtre, `passee` dans les libellés). + - L'erreur de chargement des Snapshots n'est jamais affichée. + - La carte KPI du tableau de bord répète sa valeur principale. + +**Choix validés** : thème **clair par défaut** ; menu **regroupé et renommé**, avec Snapshots et Exports fusionnés en « Historique » ; livraison **par lots, une PR par lot**, sur la branche `claude/keen-maxwell-hbtho6`. + +Conventions du dépôt : +- préfixe `rtk` sur toutes les commandes ; +- commits en français ; +- plan de chaque lot rangé selon BMAD (Business / Model / API / Data, `.agents/rules/general.md`). + +--- + +## Lot 1 — Vitesse et bugs, sans changement visuel (PR 1) + +Risque faible, gain immédiat. + +**Grille (client)** +- `src/features/grid/components/grid-client.tsx` : + - retirer `_refresh` de la requête mémorisée dans `activeGridQuery` (l.86-91), puis le supprimer de l'URL après usage avec `router.replace` ; + - sortir `code1` et `code2` des dépendances du chargement : le serveur ne les lit pas. +- `grid-client.tsx:152-156` : accumuler les paquets reçus et ne mettre à jour le store que toutes les ~250 ms, plus une fois à la fin, au lieu de reconstruire à chaque paquet. +- `heatmap-grid.tsx` : + - ajouter `getRowId: r => r.codein` (corrige le bug de sélection) ; + - mémoriser `MONTHS_12` sur sa clé texte (l.628) ; + - retirer `transition-all` sur les lignes et le flou du bandeau d'en-tête. +- `export-dropdown.tsx` : + - `exceljs`, `jspdf` et `jspdf-autotable` passent en `await import()` dans les gestionnaires ; + - le composant est chargé via `next/dynamic` ; + - remplacer `useGridStore()` sans sélecteur (l.12). +- Créer `src/lib/format.ts` : instances `Intl.NumberFormat` créées une seule fois, plus `fmtEur`, `fmtQte`, `fmtPct`, `fmtDate`. Les cellules de la Grille l'utilisent ; les autres pages au lot 3. + +**Grille (serveur)** — `src/features/grid/api/get-product-rows.ts` +- Lancer la requête des métriques Qlik et celle du dernier snapshot dans le même `Promise.all` que la phase 1 (l.130-146, 410, 416). +- `qlik-network-cache.ts` : ne sélectionner que les colonnes utiles, sans le JSONB `metricsByMonth`. +- Cache mémoire : + - le borner (LRU d'environ 30 entrées) ; + - garder la déduplication des calculs en cours même en `forceRefresh` ; + - transmettre `request.signal` depuis `src/app/api/grid/rows/route.ts`. +- Après un enregistrement, **mettre à jour en place** le `codeGamme` des lignes en cache au lieu d'invalider tout le cache (`save-draft-changes.ts:73`). Nouvelle fonction `patchGridRowsCache()` à côté de `invalidateGridRowsCache()`. +- Supprimer les `console.log` des chemins chauds : `get-product-rows.ts` (l.83, 123, 148, 433, 444, 622) et `pg-ff-client.ts` (l.99, 300-303, 431, 516). + +**Cache des données FF jusqu'à la synchro nocturne** +- Créer `src/lib/ff-cache.ts` : enveloppes `unstable_cache` étiquetées `ff-data`, durée 6 h, sur le modèle déjà en place dans `dashboard/page.tsx:4-8`. Elles couvrent : + - les 3 requêtes de stock ; + - le hit-parade (clé : dates) ; + - les CA par fournisseur et par nomenclature (clé : mois et mode) ; + - `pgGetFournisseurs` ; + - `getFournisseurs` de la Grille ; + - la dernière réception par fournisseur ; + - `pgGetCommandesAuto`. +- Publicités : `fetch` avec `next: { revalidate, tags }` au lieu de `no-store`. +- À la fin du cycle nocturne (`src/features/admin/api/sync-scheduler.ts`) : invalider l'étiquette `ff-data`, puis préchauffer le tableau de bord et le stock. +- Points à vérifier : + - la signature de `revalidateTag` en Next 16 ; + - que les valeurs mises en cache passent en JSON (les `Date` deviennent des chaînes). + +**Base de données et API** +- `src/db/index.ts` : + - pool rangé dans `globalThis` ; + - `options: "-c statement_timeout=60000 -c max_parallel_workers_per_gather=0"` et `application_name`. + - `pgNoParallel` (`pg-ff-client.ts:25-33`) n'a alors plus besoin de BEGIN/SET/COMMIT : 3 allers-retours de moins par requête. +- `AbortSignal.timeout` sur tous les appels à l'API FF : `api-ff-client.ts`, `publicites/page.tsx`, appels du tableau de bord. +- Index des tables applicatives, dans `scripts/db-init.js` et `src/db/schema.ts` : + - `session_snapshots(code_fournisseur, created_at DESC)` ; + - `session_snapshots(user_id, type, created_at DESC)`. +- Divers : + - Commandes auto : supprimer le double rechargement, en retirant `router.refresh()` puisque `revalidatePath` est déjà appelé (`cadencier-client.tsx:172-177`). + - Synchronisation : PATCH envoyé à la sortie du champ, plus à chaque frappe (`admin/synchronisation/client.tsx:209-237`). + - Paramètres : un seul appel à `getSavedDatabaseConfig`. + - `log-capture.ts` : sortie immédiate si aucune capture n'est ouverte. + +**Bugs fonctionnels** +- Publicités : aligner la clé `passees`/`passee` (`publicites/client.tsx:141, 256`). +- Historique : afficher l'état d'erreur (`snapshot-list.tsx:133`). +- Tableau de bord : la carte KPI répète sa valeur (`dashboard/page.tsx:183-185`). +- Barre de résumé de la Grille : `filters.magasin` n'est jamais renseigné, donc les snapshots enregistrent toujours « TOTAL » (`floating-summary-bar.tsx:104`). + +--- + +## Lot 2 — Socle visuel, menu et vocabulaire (PR 2) + +**Thème et typographie** — `src/app/globals.css`, `theme-provider.tsx` +- `defaultTheme="light"`. +- Ajouter `@custom-variant dark (&:where(.dark, .dark *));` pour que les classes `dark:` suivent le bouton de thème. +- Ajouter `@import "tw-animate-css";`. +- Faire correspondre les jetons shadcn (`--color-background`, `--color-muted-foreground`, `--color-accent`…) aux variables de l'application. +- Contraste AA (≥ 4,5:1) pour `--text-muted` en clair et en sombre. +- Échelle typographique : + - rien sous **12 px** ; + - cellules de tableau à 13-14 px ; + - libellés en casse normale, sans MAJUSCULES `font-black tracking-widest`. +- Une seule famille de boutons (fusion de `.apple-btn-*` et `.btn-action-*`) ; définir ou retirer les classes inexistantes (`apple-btn`, `--border-subtle`, `--surface-tertiary`). + +**Composants partagés** — dans `src/components/ui/` +- `PageHeader` : titre, phrase d'explication, actions. +- `Card` / `Section`, `StatCard` (KPI avec écart N-1). +- `Badge`, `DeltaBadge`, `StoreBadge`, `GammeBadge`. +- `Tabs`, avec l'onglet actif dans l'URL. +- `Select`, `SearchInput`. +- `EmptyState`, `ErrorState`, `Skeleton`. +- `ConfirmDialog` sur `ui/dialog`. Il remplace `window.confirm`, `alert()` et les 3 modales faites à la main. +- Notifications discrètes (`sonner`, standard shadcn) à la place de `SuccessModal` pour les succès. +- **`DataTable`** : généralisé depuis `StockTable` (`stock-negatif/client.tsx:168-384`), qui gère déjà colonnes, tri, recherche, pagination et export. Ajouter en-tête collant, état vide, et infobulle sur les en-têtes. + +**Sources uniques** +- `src/lib/magasins.ts` : 292 → « Frouard (Nancy) », 579 → « Houdemont », TOTAL → « Nos 2 magasins ». Il remplace `SITE_NAMES` (dashboard), `months.ts:91-94` et les 3 copies de `SITES`. +- `src/lib/glossaire.ts` : libellé clair et infobulle pour chaque terme métier. Exemples : + - Gamme A–Z avec son sens, à reprendre de `bulk-action-toolbar.tsx:8-12` ; + - Réseau = environ `NB_MAGASINS_RESEAU` magasins (`network-trend.ts:13`) ; + - Franco, PCB, PRMP, GTIN, N-1, Init. +- Règles de vocabulaire : + - « Réseau » = les ~270 magasins uniquement ; nos 2 magasins = « Nos magasins » ; + - Famille et Sous-famille identiques partout ; + - vouvoiement partout. +- Seuils de marge : une seule constante, au lieu de 25/15 % sur le tableau de bord et 40/25 % sur la fiche. + +**Menu** — `src/components/shared/sidebar.tsx` +- Menu déplié par défaut, en groupes : + - **Au quotidien** : Accueil, Révision d'assortiment, Recherche produit, Stocks à surveiller, Commandes fournisseurs. + - **Analyses** : Meilleures ventes, Ventes par mois, Publicités. + - **Historique** : nouvelle page `/historique` qui fusionne Snapshots et Exports, avec un onglet par type ; l'onglet Exports est réservé aux admins. `/snapshots` et `/exports` y redirigent. + - **Administration** : Synchronisation, Paramètres. +- Icônes distinctes pour chaque entrée. +- `src/middleware.ts` : protéger aussi côté serveur la partie admin de l'historique. + +**En-tête et coquille** +- `header.tsx` : + - retirer la recherche globale, qui ne filtrait que la Grille ; la Grille garde la sienne ; + - afficher le vrai utilisateur ; + - réglages personnels (thème, densité de la Grille) dans un menu utilisateur accessible à tous. +- Remplacer les 11 `loading.tsx` (modale plein écran) par des squelettes de page non bloquants. +- Ajouter `app/(dashboard)/error.tsx` (message clair et bouton « Réessayer ») et `not-found.tsx`. +- Titre de l'application en français (`app/layout.tsx`). + +--- + +## Lot 3 — Migration des pages, une à une (PR 3, découpable en 3a/3b) + +Règle commune à chaque page : +- `PageHeader` avec une phrase « à quoi sert cette page » ; +- `DataTable` et composants partagés, jetons du thème ; +- retrait des enveloppes `min-h-screen bg-gray-50 p-6` (double marge) ; +- noms de magasins depuis `magasins.ts`, infobulles depuis `glossaire.ts` ; +- états vide et erreur explicites. + +| Page | Changements spécifiques | +|---|---| +| Accueil (`dashboard`) | Cartes `StatCard`. Section « Hit Parade » renommée « Top 10 d'hier ». Chaque produit mène à sa fiche. Couleurs codées en dur remplacées par les jetons. Message d'erreur compréhensible, sans trace technique. | +| Révision d'assortiment (`grid`) | **Barre d'outils** : un seul « Enregistrer » (dans la barre de résumé), un seul menu Export, un seul « Actualiser », avec les mêmes droits partout (`isAdmin` calculé côté serveur via `auth()` dans `grid/page.tsx`). Les actions rares (Non travaillés, Vues, Colonnes, Synchro Qlik) passent dans « Plus d'options ». **Libellés** : gammes affichées avec leur sens (« A — Cœur ») ; menu Colonnes avec de vrais libellés, pas les identifiants ; « / 270 » remplacé par `NB_MAGASINS_RESEAU`. **Chargement** : la superposition floue est remplacée par une barre de progression légère. **Accueil fournisseur** : une phrase-guide. | +| Recherche produit (`produits`) | Tailles ≥ 12 px dans `fiche.tsx`, `results.tsx` et `opportunites.tsx`. Infobulles PCB, PRMP, GTIN. Famille et Sous-famille alignées. Message de chargement sans « Qlik Sense ». | +| Meilleures ventes (`hit-parade`) | `DataTable` paginé ; état vide ; « % Marge » triable. Export via `exceljs` en import dynamique, ce qui permet de retirer `xlsx`. | +| Stocks à surveiller (`stock-negatif`) | Base du `DataTable`. En-têtes Excel lisibles (« Code article » au lieu de CODEIN, etc.). Noms de magasins. | +| Publicités | Le tri et le filtre magasin s'appliquent à l'ensemble des données, pas seulement à la page de 50. Infobulles « Taux de sortie » et « % CA total ». | +| Commandes fournisseurs (`commandes-auto`) | Onglets dans l'URL. Confirmation avant suppression. Erreurs affichées en notification. Retour visuel à l'enregistrement d'une fréquence. Infobulle « Franco ». | +| Ventes par mois (`analytics`) | Dernier **mois complet** par défaut. Mois en clair (« oct. 2026 vs oct. 2025 »). Recherche, tri, export, état vide. | +| Historique | Page fusionnée (lot 2), libellés de session lisibles. | +| Paramètres | Onglets Connexions / Utilisateurs / Clés API / Journal. Un bouton « Enregistrer » par section (le bouton collant trompeur disparaît). Thème et densité déplacés dans le menu utilisateur. | +| Synchronisation | Unités affichées (« jours », « heures »). Filtres en casse normale. Libellés sans jargon (« Tour bouclé » → « Tous les fournisseurs traités »). | +| Connexion | Libellés lisibles. Slogan « Propulsée par l'IA » retiré. Rendu clair. | + +--- + +## Lot 4 — Optimisations lourdes (PR 4) + +**Grille servie depuis l'instantané `grid_rows`** (le plus gros gain) +- La table est déjà remplie chaque nuit avec exactement les `ProductRow` du magasin TOTAL : `persistGridSnapshot` dans `get-product-rows.ts`, et `sync-scheduler.ts:173`. +- Ajouter `readGridRowsPayload(codeFournisseur)` dans `src/lib/grid-store.ts`, qui lit `payload::text` et l'envoie tel quel, sans analyse ni réencodage. +- Superposer ensuite la gamme INIT à jour (`refreshGammeInit`) et les gammes du dernier snapshot. +- Si l'instantané est trop ancien, relancer le calcul en arrière-plan (stale-while-revalidate). +- Ouverture à froid : de 10-43 s à environ 1-2 s. + +**Changement de magasin instantané** +- Toujours charger TOTAL : les lignes portent déjà le détail par magasin. +- Bascule côté client avec `setActiveMagasin` et `history.replaceState`, sans aller-retour serveur (`grid-filter-bar.tsx:100-105`). +- Le rattrapage via l'API FF (`reconcileSelectedStoreFromMensuelApi`) devient une petite requête séparée, `/api/grid/rows/store-patch`, appliquée quand elle arrive. + +**Rendu de la Grille** +- `React.memo` sur `HeatmapGrid`, `GridFilterBar` et `FloatingSummaryBar`. +- `useSaveDrafts` déplacé dans le bouton Enregistrer. +- En-tête de tableau mémorisé ; `useFlushSync: false` sur le virtualiseur. +- Largeurs de colonnes en variables CSS. +- `persist` de zustand sans les lignes, et limité en fréquence. +- Tendance réseau et texte de recherche calculés une seule fois par ligne à la réception ; le filtre global ne porte plus que sur ce texte. +- Lignes gardées en mémoire au retour sur la Grille (même fournisseur, moins de 10 min) : pas de `setRows([])`. + +**SQL sans index utilisable** + +À valider par `EXPLAIN ANALYZE` sur la base réelle avant fusion : le schéma des tables FF n'est pas dans le dépôt. + +- Fiche produit (`get-produit-fiche.ts`, `pg-ff-client.ts:1737-2045`) : résoudre `no_id` une fois, puis filtrer sur les clés ; lecture du cache Qlik dans le même `Promise.all` ; supprimer la double recherche du chemin `?cc=`. +- Ventes par mois (`pg-ff-client.ts:827-885`) : plages de dates au lieu de `TO_CHAR`. +- Stocks : réécrire `pgGetStockSansVente` (l.1198-1235) en agrégeant d'abord par (article, site). Charger seulement l'onglet actif. +- Meilleures ventes (l.922-991) : jointure sur `artnoid` / `no_id`. +- Commandes : la dernière réception est limitée aux fournisseurs du cadencier et à une fenêtre de dates ; appels « franco » à concurrence bornée, avec délai maximal ; un `Suspense` par onglet. +- Tableau de bord (l.623-797) : une seule requête SQL sur `cube_stock`, en réutilisant `pgGetStockForCodeins`, au lieu de ~60 appels HTTP. Cache par date, préchauffé après la synchro. +- `/api/v1/grid` : pagination par curseur (keyset) au lieu d'OFFSET ; COUNT une seule fois. +- `api-auth.ts` : `last_used_at` mis à jour au plus une fois par minute. + +**Nettoyage** +- Dépendances inutilisées : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, et `xlsx` après le lot 3. +- Code mort : `supplier-selection.tsx`, `financial-cell.tsx`, `ui/textarea.tsx`, `getGridData` et `getAvailableNomenclature` (`grid/actions.ts`). + +--- + +## Hors périmètre, à signaler + +`src/features/settings/actions.ts` ne vérifie aucun droit. Tout utilisateur connecté peut lire l'URL de la base, le mot de passe Qlik et les clés IA, et réécrire la configuration. Ce point est proposé en tâche séparée. + +## Vérification (à chaque lot) + +1. **Contrôles automatiques** : + - `rtk tsc` : aucune nouvelle erreur par rapport à `tsc_errors.log`, car `ignoreBuildErrors` est activé. + - `rtk lint`. + - `rtk next build` : comparer le JS de premier chargement de `/grid` avant et après (lot 1 : − `exceljs` / `jspdf`). +2. **Visuel** : + - `rtk npm run dev`, puis Playwright (Chromium préinstallé) pour capturer chaque page en clair et en sombre, en 1920 et 1366 px de large. + - Un script dans la page signale tout texte calculé sous 12 px et tout élément `bg-white` en mode sombre. +3. **Vitesse** : + - mesurer le temps de réponse de `/api/grid/rows` (froid, chaud, après enregistrement), et de `/stock-negatif` et `/hit-parade` au 1er puis au 2e chargement ; + - objectif : 2e visite en moins de 200 ms, Grille froide en moins de 2 s au lot 4. +4. **Recette manuelle sur l'environnement réel** (ce conteneur n'a pas accès à PostgreSQL FF, à l'API FF ni à Qlik) : + - gros fournisseur ; + - changement de magasin instantané ; + - enregistrer puis rouvrir sans attente ; + - sélection multiple après filtre, sur les bons produits ; + - après « Actualiser », un retour par le menu ne force plus le recalcul ; + - exports Excel identiques. + +--- + +## Suivi — Lot 1 réalisé + +Écarts par rapport au plan, décidés pendant l'implémentation : + +- **Cache FF** : un cache mémoire dédié (`src/lib/ff-cache.ts`) remplace `unstable_cache`. Deux raisons : + - `unstable_cache` sérialise en JSON, ce qui casse les `Map` et les `Date` ; + - `revalidateTag` ne peut pas être appelé depuis la synchro nocturne, qui tourne hors requête. + + Fonctionnement : + - durée de vie de 30 min (`FF_CACHE_TTL_S`), et 10 min pour les publicités ; + - les appels simultanés partagent une seule requête ; + - les erreurs ne sont jamais gardées ; + - le cache est vidé en fin de synchro nocturne. +- **`statement_timeout`** : activé seulement si `PG_STATEMENT_TIMEOUT_MS` est défini, pour ne pas couper les longs calculs nocturnes. +- **Colonnes Qlik** : non modifié. `metricsByMonth` sert à la courbe « nombre de magasins vendeurs ». +- **Carte KPI du tableau de bord** : non modifiée. La ligne « Hier / N-1 » est une comparaison voulue, pas un doublon. +- **Préchauffage après la synchro** : reporté. L'invalidation suffit, la première visite recharge. diff --git a/scripts/db-init.js b/scripts/db-init.js index 3e242e8..912a925 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -61,6 +61,15 @@ async function main() { "created_at" timestamp DEFAULT now() ); `); + // Index : la Grille relit le dernier snapshot du fournisseur à chaque calcul + // et à chaque enregistrement, et l'historique liste ceux de l'utilisateur. + // Sans eux, chaque lecture parcourait toute la table (qui ne fait que grossir). + await tempPool.query(` + CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date" + ON "session_snapshots" ("code_fournisseur", "created_at" DESC); + CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date" + ON "session_snapshots" ("user_id", "type", "created_at" DESC); + `); console.log("[DB Init] Table session_snapshots is verified/created."); await tempPool.query(` diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 0158b44..929e1e1 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, CheckCircle, Database, Network, Search, @@ -114,6 +114,10 @@ export function SynchronisationClient() { return () => clearInterval(id); }, [state?.enCours, rafraichirEtat]); + // Saisie numérique pas encore envoyée (cf. majSettingsDifferee). + const patchEnAttente = useRef>({}); + const minuteurPatch = useRef | null>(null); + const majSettings = async (patch: Partial) => { setErreur(null); try { @@ -122,10 +126,26 @@ export function SynchronisationClient() { }); const d = await res.json(); if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); - setSettings(d.settings); + // Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse. + setSettings({ ...d.settings, ...patchEnAttente.current }); } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } }; + // Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur + // est regroupé 600 ms après la dernière frappe. Avant, chaque chiffre tapé + // partait en PATCH, et une réponse tardive pouvait écraser la saisie en cours. + const majSettingsDifferee = (patch: Partial) => { + setSettings((prev) => (prev ? { ...prev, ...patch } : prev)); + patchEnAttente.current = { ...patchEnAttente.current, ...patch }; + if (minuteurPatch.current) clearTimeout(minuteurPatch.current); + minuteurPatch.current = setTimeout(() => { + const aEnvoyer = patchEnAttente.current; + patchEnAttente.current = {}; + minuteurPatch.current = null; + void majSettings(aEnvoyer); + }, 600); + }; + const action = async (act: "start" | "stop" | "seed", forcer = false) => { setErreur(null); try { @@ -207,7 +227,7 @@ export function SynchronisationClient() {
majSettings({ heureDebut: Number(e.target.value) })} + onChange={(e) => majSettingsDifferee({ 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)" }} />
@@ -215,7 +235,7 @@ export function SynchronisationClient() {
majSettings({ heureFin: Number(e.target.value) })} + onChange={(e) => majSettingsDifferee({ 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)" }} />
@@ -225,14 +245,14 @@ export function SynchronisationClient() {
majSettings({ qlikParNuit: Number(e.target.value) })} + onChange={(e) => majSettingsDifferee({ 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)" }} />
majSettings({ qlikMinJours: Number(e.target.value) })} + onChange={(e) => majSettingsDifferee({ 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)" }} />
diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 1bbe515..ce3a997 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -1,5 +1,6 @@ import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client"; import { AnalyticsClient } from "./client"; +import { cachedFF } from "@/lib/ff-cache"; // Calcul du mois N-1 (même mois année précédente) function getMoisN1(mois: string): string { @@ -84,7 +85,7 @@ export default async function AnalyticsPage(props: { []; if (mode === "fournisseur") { - const result = await pgGetCaByFournisseur(mois, moisN1); + const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1)); rows = result.map((r) => ({ code: r.code, label: r.nom, @@ -93,7 +94,7 @@ export default async function AnalyticsPage(props: { ca_ttc: r.ca_ttc, })); } else { - const result = await pgGetCaByNomenclature(mois, moisN1); + const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1)); rows = result.map((r) => ({ code: r.code, label: `${r.code} — ${r.libelle}`, diff --git a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx index 0626ea4..eea3f3b 100644 --- a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx +++ b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; import { AlertTriangle, CalendarClock, @@ -162,17 +161,17 @@ function SiteSection({ rows: CadenceView[]; fournisseurs: { code: string; nom: string }[]; }) { - const router = useRouter(); const [pending, startTransition] = useTransition(); const sorted = useMemo(() => trier(rows), [rows]); const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]); const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length; + // Pas de router.refresh() : les actions appellent déjà revalidatePath, dont + // la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois. function run(fn: () => Promise) { startTransition(async () => { await fn(); - router.refresh(); }); } diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index 5b5d287..3298dc4 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -1,6 +1,7 @@ import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions"; import { CommandesAutoTabs } from "./tabs"; +import { cachedFF } from "@/lib/ff-cache"; export type { PgCommandeAutoRow }; @@ -9,7 +10,13 @@ export const dynamic = "force-dynamic"; export default async function CommandesAutoPage() { const [rows, cadences, fournisseurs] = await Promise.all([ - pgGetCommandesAuto(), + // Propositions de l'API FF (un appel, plus un par fournisseur sans franco) : + // gardées 2 minutes, pour que chaque modification du cadencier — qui + // re-rend la page — n'attende pas de nouveau toute l'API. + cachedFF("commandes-auto", () => pgGetCommandesAuto(), { + ttlMs: 2 * 60 * 1000, + cacheIf: (rows) => rows.length > 0, + }), listCadences(), getFournisseursPourCadence(), ]); diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx index ec4b18a..de939fe 100644 --- a/src/app/(dashboard)/hit-parade/client.tsx +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -2,7 +2,6 @@ import { useRouter } from "next/navigation"; import { useState, useMemo } from "react"; -import * as XLSX from "xlsx"; import type { HitParadePivotRow } from "./page"; type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579"; @@ -136,7 +135,9 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 } ), [sorted]); - function exportToExcel() { + async function exportToExcel() { + // Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko. + const XLSX = await import("xlsx"); const headers = [ "Code", "Désignation", "Fournisseur", "Nomenclature", "Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy", diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx index b207512..43d9d8b 100644 --- a/src/app/(dashboard)/hit-parade/page.tsx +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -1,5 +1,6 @@ import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client"; import { HitParadeClient } from "./client"; +import { cachedFF } from "@/lib/ff-cache"; export interface HitParadePivotRow { codein: string; @@ -80,7 +81,7 @@ export default async function HitParadePage(props: { const dateDebut = (searchParams.debut as string) || defaults.debut; const dateFin = (searchParams.fin as string) || defaults.fin; - const rows = await pgGetHitParade(dateDebut, dateFin); + const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin)); const pivotted = pivotHitParade(rows); return ( diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index e9e0ba7..4753017 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -26,6 +26,8 @@ function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; s const STATUT_LABELS: Record = { en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" }, passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" }, + // Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes. + passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" }, a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" }, }; diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 7924c52..818fb37 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -1,4 +1,5 @@ import { PublicitesClient } from "./client"; +import { cachedFF } from "@/lib/ff-cache"; export interface Publicite { tcr_code: string; @@ -40,17 +41,31 @@ export interface PubliciteHistorique { const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; const PAGE_SIZE = 50; +/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */ +const FF_API_TIMEOUT_MS = 15_000; +/** + * Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste + * et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page. + */ +const PUB_CACHE_TTL_MS = 10 * 60 * 1000; -async function fetchPublicites(statut: string, page: number): Promise<{ - data: Publicite[]; - total: number; - pages: number; - error?: string; -}> { +type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string }; + +function fetchPublicites(statut: string, page: number): Promise { + return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), { + ttlMs: PUB_CACHE_TTL_MS, + cacheIf: (r) => !r.error, + }); +} + +async function fetchPublicitesApi(statut: string, page: number): Promise { const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); if (statut && statut !== "toutes") params.set("statut", statut); try { - const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" }); + const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { + cache: "no-store", + signal: AbortSignal.timeout(FF_API_TIMEOUT_MS), + }); if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` }; const json = await res.json(); return { @@ -63,9 +78,21 @@ async function fetchPublicites(statut: string, page: number): Promise<{ } } -async function fetchHistorique(): Promise<{ data: PubliciteHistorique[]; error?: string }> { +type HistoriqueResult = { data: PubliciteHistorique[]; error?: string }; + +function fetchHistorique(): Promise { + return cachedFF("publicites-historique", fetchHistoriqueApi, { + ttlMs: PUB_CACHE_TTL_MS, + cacheIf: (r) => !r.error, + }); +} + +async function fetchHistoriqueApi(): Promise { try { - const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { cache: "no-store" }); + const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { + cache: "no-store", + signal: AbortSignal.timeout(FF_API_TIMEOUT_MS), + }); if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` }; const json = await res.json(); return { data: (json.publicites ?? []) as PubliciteHistorique[] }; diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 2e3cd79..73544e4 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -13,6 +13,22 @@ 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"; +/** + * Configuration enregistrée, lue UNE fois pour toute la page : trois sections + * (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions + * serveur s'exécutent les unes après les autres. + */ +let savedConfigPromise: ReturnType | null = null; +function loadSavedConfig(force = false): ReturnType { + if (force || !savedConfigPromise) { + savedConfigPromise = getSavedDatabaseConfig().catch((e) => { + savedConfigPromise = null; + throw e; + }); + } + return savedConfigPromise; +} + function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { return (
@@ -51,7 +67,7 @@ function FfApiStatusSection() { // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). useEffect(() => { - getSavedDatabaseConfig() + loadSavedConfig() .then((cfg) => { setSavedUrl(cfg?.ffApiBaseUrl ?? null); if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); @@ -177,7 +193,7 @@ function QlikSettingsSection() { const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null); useEffect(() => { - getSavedDatabaseConfig().then((c) => { + loadSavedConfig().then((c) => { if (!c) return; setHost(c.qlikHost ?? ""); setUser(c.qlikUser ?? ""); @@ -265,8 +281,8 @@ export default function SettingsPage() { getDatabaseUrl } = useDbSettingsStore(); - const reloadFromServer = useCallback(async () => { - const config = await getSavedDatabaseConfig(); + const reloadFromServer = useCallback(async (force = false) => { + const config = await loadSavedConfig(force); if (config) { if (config.url) { // Parser l'URL pour remettre dans le store @@ -290,7 +306,9 @@ export default function SettingsPage() { // 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(); + // Lecture fraîche à chaque ouverture de la page ; les sections, montées + // juste après (cf. `isMounted`), réutilisent cette même requête. + reloadFromServer(true); }, [reloadFromServer]); const testDb = async () => { @@ -410,7 +428,7 @@ export default function SettingsPage() {
); }, @@ -1033,7 +1016,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat : (row.original.caByStore?.[activeMagasin] ?? 0); return (
- {Math.round(ca).toLocaleString("fr-FR")} € + {fmtEntier(ca)} €
); }, @@ -1054,7 +1037,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat return (
- {Math.round(marge).toLocaleString("fr-FR")} € + {fmtEntier(marge)} € = 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)" @@ -1095,10 +1078,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat onColumnVisibilityChange: setColumnVisibility, onColumnSizingChange: setColumnSizing, columnResizeMode: "onChange", + // Identifiant stable : avec l'index par défaut, la sélection désignait + // d'autres produits dès qu'un filtre changeait l'ordre des lignes. + getRowId: (row) => row.codein, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, + // La recherche ne dépend pas de la colonne (elle lit libellé, code, référence + // et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par + // colonne filtrable (une quinzaine) à chaque frappe. + getColumnCanGlobalFilter: (column) => column.id === "codein", globalFilterFn: (row, _columnId, filterValue) => { const search = String(filterValue).toLowerCase(); const libelle = String(row.original.libelle1 || "").toLowerCase(); @@ -1111,6 +1101,31 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat const { rows: tableRows } = table.getRowModel(); + const prevSelectionRef = useRef([]); + + // Propagate selection changes via useEffect to avoid "update during render" error + useEffect(() => { + if (!onSelectionChange) return; + + // La sélection est indexée par code article (`getRowId`), et seules les + // lignes encore affichées comptent : une action groupée ne doit jamais + // toucher un produit masqué par un filtre ou par la recherche. + const selectedCodeins = table + .getFilteredSelectedRowModel() + .rows.map((r) => r.original.codein); + + // Only update if selection actually changed to avoid re-render loops & console warnings + const currentString = JSON.stringify(selectedCodeins); + const prevString = JSON.stringify(prevSelectionRef.current); + + if (currentString !== prevString) { + prevSelectionRef.current = selectedCodeins; + // Delay update to next tick to ensure we're out of any render cycles + setTimeout(() => onSelectionChange(selectedCodeins), 0); + } + }, [table, rowSelection, onSelectionChange, filteredData, filters.search]); + + const rowVirtualizer = useVirtualizer({ count: tableRows.length, getScrollElement: () => tableContainerRef.current, @@ -1291,8 +1306,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat {table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 59aef41..0ed4391 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback } from "react"; -import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { useGridStore } from "@/features/grid/store/use-grid-store"; import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; @@ -28,21 +27,12 @@ function formatLastUpdate(iso?: string | null): string { * dans `useQlikSyncJob`, partagée avec la fiche produit. */ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - /** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */ - const refreshGrid = useCallback(() => { - const params = new URLSearchParams(searchParams.toString()); - params.set("_refresh", String(Date.now())); - router.replace(`${pathname}?${params.toString()}`); - router.refresh(); - }, [pathname, router, searchParams]); + /** Recharge la grille en ignorant le cache serveur (la barre de résumé suit). */ + const requestRefresh = useGridStore((s) => s.requestRefresh); const { status, message, start } = useQlikSyncJob({ target: { mode: "fournisseur", fournisseur: codeFournisseur }, - onSuccess: refreshGrid, + onSuccess: requestRefresh, }); return ( diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 66627e4..efdf6da 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -16,6 +16,14 @@ interface GridState { displayDensity: "compact" | "normal" | "comfortable"; /** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */ activeGridQuery: string; + /** + * Compteur de demandes de rechargement forcé (ignore le cache serveur). + * Volontairement hors URL et non persisté : un paramètre `_refresh` dans + * l'URL restait mémorisé dans `activeGridQuery`, et chaque retour sur la + * Grille par le menu relançait un recalcul complet. + */ + refreshRequest: number; + requestRefresh: () => void; /** Persisted column visibility state */ columnVisibility: Record; /** Persisted column sizing state */ @@ -59,6 +67,15 @@ interface GridState { let summaryTimer: ReturnType | null = null; +/** Retire l'ancien paramètre `_refresh` (cache-buster) d'une query string mémorisée. */ +function sansParamRefresh(query: string): string { + if (!query.includes("_refresh")) return query; + const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query); + params.delete("_refresh"); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + function indexRows(rows: ProductRow[]): Record { const indexed: Record = {}; for (const row of rows) indexed[row.codein] = row; @@ -117,6 +134,7 @@ export const useGridStore = create()( }, displayDensity: "normal", activeGridQuery: "", + refreshRequest: 0, columnVisibility: {}, columnSizing: {}, showMonthlySales: true, @@ -202,7 +220,8 @@ export const useGridStore = create()( set((state) => ({ ...state, filters: { ...state.filters, code3: codes } })); }, setDisplayDensity: (density) => set({ displayDensity: density }), - setActiveGridQuery: (query) => set({ activeGridQuery: query }), + setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }), + requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })), restoreSnapshot: (changes) => { set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) }); }, @@ -249,8 +268,11 @@ export const useGridStore = create()( * 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. + * + * v2 — `activeGridQuery` pouvait contenir `_refresh=…` : le lien « Grille » + * du menu forçait alors un recalcul serveur complet à chaque retour. */ - version: 1, + version: 2, migrate: (persisted: unknown, version: number): GridPersisted => { const etat = (persisted ?? {}) as GridPersisted & { filters?: Record }; if (version < 1 && etat.filters) { @@ -262,6 +284,9 @@ export const useGridStore = create()( // à la fusion (le bloc disparaîtrait sans raison). if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true; if (typeof etat.showPrices !== "boolean") etat.showPrices = true; + if (typeof etat.activeGridQuery === "string") { + etat.activeGridQuery = sansParamRefresh(etat.activeGridQuery); + } return etat; }, // Only persist filters, display density, drafts, active grid query, and column visibility/sizing diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index 488aef6..bed4ca5 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -27,6 +27,8 @@ export async function getSnapshots(type?: "snapshot" | "export") { } catch (err: any) { const msg = (err?.message || String(err)).split("\n")[0]; console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err); - return []; + // Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot » + // au lieu de signaler que le chargement a échoué. + throw new Error("Chargement de l'historique impossible."); } } diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 5f072bd..1c7b336 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -13,6 +13,7 @@ import { History, ChevronRight, Loader2, + AlertCircle, } from "lucide-react"; import { SuccessModal } from "@/components/shared/success-modal"; import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; @@ -78,6 +79,7 @@ export function SnapshotList({ type }: SnapshotListProps) { const fetchSnapshots = async () => { setLoading(true); + setError(null); try { const data = await getSnapshots(type); setSnapshots(data); @@ -146,6 +148,19 @@ export function SnapshotList({ type }: SnapshotListProps) { ); } + if (error && snapshots.length === 0) { + return ( +
+ +

L'historique n'a pas pu être chargé

+

{error}

+ +
+ ); + } + if (snapshots.length === 0) { return (
diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts index e4eb42e..02e1261 100644 --- a/src/lib/api-ff-client.ts +++ b/src/lib/api-ff-client.ts @@ -16,6 +16,15 @@ const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json"); const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr"; const FF_API_BASE_TTL_MS = 30_000; +/** + * Options de tout appel à l'API FF : jamais de cache HTTP, et un délai maximal. + * Sans délai, une API muette figeait indéfiniment la page qui l'attendait. + */ +const FF_FETCH_TIMEOUT_MS = Number(process.env.FF_API_TIMEOUT_MS) || 30_000; +export function ffFetchInit(): RequestInit { + return { cache: "no-store", signal: AbortSignal.timeout(FF_FETCH_TIMEOUT_MS) }; +} + let ffApiBaseCache: { value: string; at: number } | null = null; /** @@ -201,7 +210,7 @@ async function fetchAllPages( // Page 1 — récupère les items et tente d'obtenir le total pour paralléliser const url1 = buildUrl(1); console.log(`[api-ff] GET ${url1}`); - const res1 = await fetch(url1, { cache: "no-store" }); + const res1 = await fetch(url1, ffFetchInit()); if (!res1.ok) { console.error(`[api-ff] HTTP ${res1.status} — ${url1}`); return []; @@ -227,7 +236,7 @@ async function fetchAllPages( const results = await Promise.all(chunk.map(async (p) => { const url = buildUrl(p); console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, ffFetchInit()); if (!res.ok) return [] as T[]; const data = await res.json(); return extractList(data) as T[]; @@ -245,7 +254,7 @@ async function fetchAllPages( const results = await Promise.all(chunk.map(async (p) => { const url = buildUrl(p); console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, ffFetchInit()); if (!res.ok) return { p, items: [] as T[] }; const data = await res.json(); return { p, items: extractList(data) as T[] }; @@ -311,7 +320,7 @@ export async function getFournisseursFromApi( : `${FF_API_BASE}/api/fournisseurs?limit=500`; console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, ffFetchInit()); if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`); const data = await res.json(); @@ -468,7 +477,7 @@ export async function getMensuelByArticles( if (!noId) return; try { const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`; - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, ffFetchInit()); if (!res.ok) return; const data = await res.json(); const entries: FfMensuelEntry[] = Array.isArray(data.data) ? data.data : []; @@ -555,7 +564,7 @@ export async function getReferentielByArticles( if (!noId) return; try { const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`; - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, ffFetchInit()); if (!res.ok) return; const data: FfReferentiel = await res.json(); if (data?.article) result.set(art.codein, data); @@ -581,7 +590,7 @@ export async function getCommandesByFournisseur( try { const res = await fetch( `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, - { cache: "no-store" } + ffFetchInit() ); if (!res.ok) return result; const data = await res.json(); @@ -602,7 +611,7 @@ export async function getCommandesByFournisseur( export async function getSyncStatus(): Promise { try { const FF_API_BASE = await getFfApiBase(); - const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" }); + const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit()); if (!res.ok) return null; return normalizeSyncStatus(await res.json()); } catch (err) { diff --git a/src/lib/ff-cache.ts b/src/lib/ff-cache.ts new file mode 100644 index 0000000..6fdda70 --- /dev/null +++ b/src/lib/ff-cache.ts @@ -0,0 +1,111 @@ +/** + * CollectFlow — Cache mémoire des lectures « FF » (base miroir et API FF Nancy). + * + * Les tables FF (`mvtart`, `cube_stock`, `articles`…) ne sont recopiées qu'une + * fois par nuit depuis SQL Server : recalculer à chaque visite les mêmes + * agrégations lourdes (stock, hit-parade, CA mensuel…) coûtait plusieurs + * secondes par page pour un résultat identique. + * + * Pourquoi pas `unstable_cache` : il sérialise en JSON (une `Map` devient `{}`, + * une `Date` une chaîne) et ne peut pas être invalidé hors d'une requête — or + * c'est la synchro nocturne, lancée par un minuteur, qui sait quand les données + * changent. Ici : + * - une durée de vie bornée (FF_CACHE_TTL_S, 30 min par défaut) ; + * - les appels simultanés pour la même clé partagent une seule requête ; + * - une erreur n'est jamais mise en cache ; + * - `invalidateFFCache()` vide tout (appelé en fin de synchro nocturne). + */ + +import "server-only"; + +import { pgGetDerniereReceptionParFournisseur, pgGetFournisseurs } from "@/lib/pg-ff-client"; + +const TTL_MS = (Number(process.env.FF_CACHE_TTL_S) || 30 * 60) * 1000; +/** Plafond d'entrées : les clés incluent des paramètres (dates, mois, magasin). */ +const MAX_ENTRIES = 100; + +interface Entry { + value?: unknown; + expiresAt: number; + pending?: Promise; +} + +// Rangé dans globalThis : un seul cache par processus, même si ce module est +// présent dans plusieurs lots compilés (routes, instrumentation). +const globalForFFCache = globalThis as typeof globalThis & { + __collectflowFFCache?: { store: Map; generation: number }; +}; +const cache = (globalForFFCache.__collectflowFFCache ??= { store: new Map(), generation: 0 }); +const store = cache.store; + +interface CachedOptions { + /** Durée de vie propre à cette clé, en millisecondes. */ + ttlMs?: number; + /** + * Ne garder le résultat que s'il passe ce test. Sert aux lectures qui + * avalent leurs erreurs et renvoient une liste vide : sans ce garde-fou, + * une panne passagère resterait affichée pendant toute la durée de vie. + */ + cacheIf?: (value: T) => boolean; +} + +export async function cachedFF(key: string, load: () => Promise, options: CachedOptions = {}): Promise { + const now = Date.now(); + const entry = store.get(key); + if (entry && "value" in entry && entry.expiresAt > now) return entry.value as T; + if (entry?.pending) return entry.pending as Promise; + + // Une invalidation pendant le chargement rend son résultat suspect : il est + // alors renvoyé à l'appelant, mais pas conservé. + const generation = cache.generation; + const pending = load().then( + (value) => { + if (generation !== cache.generation) return value; + if (options.cacheIf && !options.cacheIf(value)) { + store.delete(key); + } else { + store.delete(key); // réinsertion en fin : ordre d'éviction = ancienneté + store.set(key, { value, expiresAt: Date.now() + (options.ttlMs ?? TTL_MS) }); + while (store.size > MAX_ENTRIES) { + const oldest = store.keys().next().value; + if (oldest === undefined) break; + store.delete(oldest); + } + } + return value; + }, + (error) => { + if (generation === cache.generation) store.delete(key); + throw error; + }, + ); + // Une éventuelle valeur expirée est remplacée : seule la requête en cours compte. + store.set(key, { expiresAt: 0, pending }); + return pending; +} + +/** Vide tout le cache FF (fin de synchro nocturne, données fraîches). */ +export function invalidateFFCache(): void { + cache.generation++; + store.clear(); +} + +// --------------------------------------------------------------------------- +// Lectures partagées par plusieurs pages +// --------------------------------------------------------------------------- + +/** Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. */ +export function getFournisseursCached(): Promise<{ code: string; nom: string }[]> { + return cachedFF("fournisseurs", () => pgGetFournisseurs(), { cacheIf: (rows) => rows.length > 0 }); +} + +/** + * Dernière réception par fournisseur et magasin (cadencier). Agrège tout + * l'historique de `mvtart` : de loin la lecture la plus lourde de la page + * Commandes. Vide = erreur avalée, non gardé. + */ +export function getDerniereReceptionCached(): Promise> { + return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), { + cacheIf: (map) => map.size > 0, + }); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..32f16c9 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,30 @@ +/** + * Formateurs de nombres partagés (fr-FR). + * + * `Number.prototype.toLocaleString` appelé avec des options construit un nouvel + * `Intl.NumberFormat` à chaque appel : dans la Grille, plusieurs milliers de + * cellules le payaient à chaque défilement et à chaque tri. Ces instances sont + * créées une seule fois et donnent exactement le même texte. + */ + +const nfEntier = new Intl.NumberFormat("fr-FR"); +const nfDecimal1 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 1 }); +const nfEur0 = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); +const nfEur2 = new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +/** Entier arrondi avec séparateur de milliers : `12 345`. */ +export const fmtEntier = (v: number): string => nfEntier.format(Math.round(v)); + +/** Nombre avec au plus une décimale : `12,5`. */ +export const fmtDecimal1 = (v: number): string => nfDecimal1.format(v); + +/** Montant en euros sans centimes : `12 345 €`. */ +export const fmtEur0 = (v: number): string => nfEur0.format(v); + +/** Montant en euros au centime (prix unitaires) : `3,49 €`. */ +export const fmtEur2 = (v: number): string => nfEur2.format(v); diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts index 2394bfb..1ebb8e6 100644 --- a/src/lib/grid-store.ts +++ b/src/lib/grid-store.ts @@ -14,7 +14,7 @@ import "server-only"; import { db } from "@/db"; import { gridRows } from "@/db/schema"; -import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm"; import type { ProductRow } from "@/types/grid"; /** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */ @@ -147,6 +147,37 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[] return written; } +/** + * Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload), + * pour que /api/v1 les voie sans attendre un recalcul complet de la grille. + */ +export async function updateGridRowsGamme( + codeFournisseur: string, + changes: ReadonlyArray<{ codein: string; codeGamme: string }>, +): Promise { + const parGamme = new Map(); + for (const c of changes) { + const codeins = parGamme.get(c.codeGamme) ?? []; + codeins.push(c.codein); + parGamme.set(c.codeGamme, codeins); + } + const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL + for (const [gamme, codeins] of parGamme) { + for (let i = 0; i < codeins.length; i += CHUNK) { + await db + .update(gridRows) + .set({ + codeGamme: gamme, + payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`, + }) + .where(and( + eq(gridRows.codeFournisseur, codeFournisseur), + inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), + )); + } + } +} + /** Construit la clause WHERE commune à la grille et à la recherche transversale. */ function buildWhere(q: GridQuery): SQL | undefined { const clauses: SQL[] = []; diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts index 364245e..e3ddf71 100644 --- a/src/lib/log-capture.ts +++ b/src/lib/log-capture.ts @@ -22,7 +22,7 @@ * un job qui fait tomber le process laisse quand même son journal. */ -import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -35,7 +35,7 @@ const MAX_LIGNES = 200_000; const MAX_LONGUEUR_LIGNE = 8_000; /** Nombre de journaux conservés sur disque. */ const MAX_FICHIERS = 20; -/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */ +/** Écriture disque au plus toutes les N ms (seules les nouvelles lignes sont ajoutées). */ const FLUSH_MS = 2_000; interface Capture { @@ -46,6 +46,8 @@ interface Capture { flushEnCours: boolean; flushDemande: boolean; dernierFlush: number; + /** Nombre de lignes déjà écrites sur disque. */ + ecrites: number; } const captures = new Map(); @@ -82,7 +84,16 @@ async function flush(capture: Capture): Promise { capture.flushEnCours = true; try { await mkdir(LOG_DIR, { recursive: true }); - await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8"); + // Ajout des seules nouvelles lignes : réécrire tout le journal (jusqu'à + // 200 000 lignes) toutes les 2 s bloquait le serveur pendant les synchros. + const total = capture.lignes.length; + const nouvelles = capture.lignes.slice(capture.ecrites, total); + if (capture.ecrites === 0) { + await writeFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); + } else if (nouvelles.length > 0) { + await appendFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); + } + capture.ecrites = total; capture.dernierFlush = Date.now(); } catch { // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe. @@ -128,7 +139,9 @@ function instrumenterConsole(): void { const origine = console[methode].bind(console); console[methode] = (...args: unknown[]) => { try { - ajouter(formater(args)); + // Aucune capture ouverte (le cas courant) : on ne formate rien. + // Sérialiser chaque argument de chaque log coûtait à toutes les requêtes. + if (captures.size > 0) ajouter(formater(args)); } catch { // idem : jamais au détriment du log réel } @@ -173,6 +186,7 @@ export function startCapture(id: string, entete?: string): void { flushEnCours: false, flushDemande: false, dernierFlush: 0, + ecrites: 0, }; captures.set(id, capture); capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`); diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 38fdf12..69699ce 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -9,6 +9,7 @@ import { db } from "@/db"; import { sql, type SQL } from "drizzle-orm"; +import { ffFetchInit } from "@/lib/api-ff-client"; const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; @@ -20,16 +21,13 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined // Utilitaire : exécuter une requête sans workers parallèles PostgreSQL. // Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font // échouer les requêtes si le conteneur a un shm_size insuffisant. -// SET LOCAL s'applique uniquement à la transaction courante. +// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) : +// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait +// trois allers-retours de plus à chacune. // --------------------------------------------------------------------------- async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> { - let rows: unknown[] = []; - await db.transaction(async (tx) => { - await tx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`); - const r = await tx.execute(query); - rows = r.rows; - }); - return { rows }; + const r = await db.execute(query); + return { rows: r.rows }; } // --------------------------------------------------------------------------- @@ -96,7 +94,6 @@ export async function pgGetFournisseurs(search?: string): Promise<{ code: string ${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``} ORDER BY fi.nom `); - console.log(`[pg-ff] pgGetFournisseurs: ${result.rows.length} fournisseurs`); return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom); } catch (e) { console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200)); @@ -297,10 +294,6 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise 0) { - console.log("[pg-ff] Gammes sample row:", JSON.stringify(result.rows[0])); - } - console.log(`[pg-ff] Gammes: ${map.size} articles avec gamme`); return map; } @@ -429,7 +422,6 @@ export async function pgGetNomenclatureByFournisseur(codefou: string): Promise 0) console.log("[pg-ff] Nomenclature sample:", JSON.stringify(result.rows[0])); return buildNomMap(result.rows as unknown as PgNomRow[]); } @@ -635,9 +627,9 @@ export async function pgGetDashboardData(): Promise { // Deux appels parallèles : données hier + données même jour N-1 const [apiResult, apiN1Result] = await Promise.all([ - fetch(`${apiBase}/dashboard?date=${dateHier}`, { cache: "no-store" }) + fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit()) .then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise; }), - fetch(`${apiBase}/dashboard?date=${dateN1Param}`, { cache: "no-store" }) + fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit()) .then(r => r.ok ? r.json() as Promise : null) .catch(() => null), ]); @@ -686,7 +678,7 @@ export async function pgGetDashboardData(): Promise { const batch = allCodeins.slice(i, i + 5); await Promise.all(batch.map(async (codein) => { try { - const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`); + const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit()); if (!artRes.ok) return; const artData = await artRes.json(); if (!artData.articles || artData.articles.length === 0) return; @@ -695,8 +687,8 @@ export async function pgGetDashboardData(): Promise { if (!noId) return; const [refRes, dernierRes] = await Promise.all([ - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`), - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`), + fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()), + fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()), ]); // Stock (toujours depuis referentiel) @@ -1373,7 +1365,7 @@ function mapCommandeAutoRow(r: any): PgCommandeAutoRow { export async function pgGetCommandesAuto(): Promise { try { - const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" }); + const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit()); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // L'API retourne { count: N, propositions: [...] } ou un tableau direct @@ -1414,7 +1406,7 @@ export async function pgGetCommandesAuto(): Promise { uniqueCodeFous.map(async codefou => { // 1. Essai sans filtre site try { - const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, { cache: "no-store" }); + const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit()); if (res2.ok) { const detail = await res2.json(); const f = extractFranco(detail); @@ -1423,7 +1415,7 @@ export async function pgGetCommandesAuto(): Promise { } catch { /* ignore */ } // 2. Fallback site=000 try { - const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, { cache: "no-store" }); + const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit()); if (res3.ok) { const detail = await res3.json(); const f = extractFranco(detail); From 0c890c84c5c17bf0b087cf7d26d80bcb4ad39100 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:45:03 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20lot=202=20=E2=80=94=20socle=20v?= =?UTF-8?q?isuel,=20menu=20regroup=C3=A9=20et=20composants=20communs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thème - Clair par défaut ; les classes dark: suivent le bouton de thème (et non plus le réglage du système) ; animations tw-animate-css enfin importées. - Couleurs shadcn branchées sur les variables (survols des menus visibles), libellés gris au contraste AA dans les deux thèmes, fond plein pour le menu et l'en-tête. Composants partagés (src/components/ui) - PageHeader, Card/Field, StatCard, Badge/DeltaBadge/StoreBadge/GammeBadge, Tabs/Segmented/useUrlTab, Select/SearchInput, EmptyState/ErrorState/ Skeleton, Tooltip/Terme (glossaire), toast et confirmer, Pagination, DataTable (recherche, filtres, tri, pagination, export, totaux). - Sources uniques : magasins, gammes (avec leur sens), glossaire, seuils de marge, export Excel. Navigation - Menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié par défaut, libellés en français. - En-tête : nom de la page, thème, menu du compte avec le vrai nom, la hauteur des lignes de la Grille et la déconnexion. La recherche globale, qui ne filtrait que la Grille, est retirée. - Page Historique (sessions + exports) ; /snapshots et /exports y redirigent. - Squelettes de chargement au lieu de la fenêtre plein écran, pages d'erreur et « introuvable » en français, arrivée sur l'Accueil après connexion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn --- docs/plan-optimisation-webui.md | 34 ++ src/app/(dashboard)/analytics/loading.tsx | 7 +- .../(dashboard)/commandes-auto/loading.tsx | 7 +- src/app/(dashboard)/dashboard/loading.tsx | 7 +- src/app/(dashboard)/error.tsx | 28 ++ src/app/(dashboard)/exports/loading.tsx | 5 - src/app/(dashboard)/exports/page.tsx | 24 +- src/app/(dashboard)/grid/loading.tsx | 7 +- src/app/(dashboard)/historique/page.tsx | 27 ++ src/app/(dashboard)/historique/tabs.tsx | 23 ++ src/app/(dashboard)/hit-parade/loading.tsx | 7 +- src/app/(dashboard)/layout.tsx | 2 +- src/app/(dashboard)/not-found.tsx | 17 + src/app/(dashboard)/produits/loading.tsx | 7 +- src/app/(dashboard)/publicites/loading.tsx | 7 +- src/app/(dashboard)/settings/loading.tsx | 7 +- src/app/(dashboard)/snapshots/loading.tsx | 5 - src/app/(dashboard)/snapshots/page.tsx | 24 +- src/app/(dashboard)/stock-negatif/loading.tsx | 7 +- src/app/globals.css | 59 ++- src/app/layout.tsx | 4 +- src/app/not-found.tsx | 19 + src/app/providers.tsx | 11 +- src/components/shared/header.tsx | 147 ++++--- src/components/shared/navigation.ts | 77 ++++ src/components/shared/sidebar.tsx | 214 +++++------ src/components/shared/theme-provider.tsx | 2 +- src/components/ui/badge.tsx | 77 ++++ src/components/ui/card.tsx | 45 +++ src/components/ui/data-table.tsx | 359 ++++++++++++++++++ src/components/ui/dialog.tsx | 4 +- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/feedback.tsx | 162 ++++++++ src/components/ui/form-controls.tsx | 92 +++++ src/components/ui/page-header.tsx | 45 +++ src/components/ui/pagination.tsx | 50 +++ src/components/ui/stat-card.tsx | 45 +++ src/components/ui/states.tsx | 101 +++++ src/components/ui/tabs.tsx | 126 ++++++ src/components/ui/tooltip.tsx | 76 ++++ src/features/auth/api/login-action.ts | 2 +- src/lib/export-excel.ts | 53 +++ src/lib/gammes.ts | 66 ++++ src/lib/glossaire.ts | 97 +++++ src/lib/magasins.ts | 48 +++ src/lib/marge.ts | 33 ++ src/middleware.ts | 6 +- 47 files changed, 2004 insertions(+), 272 deletions(-) create mode 100644 src/app/(dashboard)/error.tsx delete mode 100644 src/app/(dashboard)/exports/loading.tsx create mode 100644 src/app/(dashboard)/historique/page.tsx create mode 100644 src/app/(dashboard)/historique/tabs.tsx create mode 100644 src/app/(dashboard)/not-found.tsx delete mode 100644 src/app/(dashboard)/snapshots/loading.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/shared/navigation.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/data-table.tsx create mode 100644 src/components/ui/feedback.tsx create mode 100644 src/components/ui/form-controls.tsx create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/stat-card.tsx create mode 100644 src/components/ui/states.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/export-excel.ts create mode 100644 src/lib/gammes.ts create mode 100644 src/lib/glossaire.ts create mode 100644 src/lib/magasins.ts create mode 100644 src/lib/marge.ts diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md index 2719fca..f68e243 100644 --- a/docs/plan-optimisation-webui.md +++ b/docs/plan-optimisation-webui.md @@ -270,3 +270,37 @@ Règle commune à chaque page : - **Colonnes Qlik** : non modifié. `metricsByMonth` sert à la courbe « nombre de magasins vendeurs ». - **Carte KPI du tableau de bord** : non modifiée. La ligne « Hier / N-1 » est une comparaison voulue, pas un doublon. - **Préchauffage après la synchro** : reporté. L'invalidation suffit, la première visite recharge. + +## Suivi — Lot 2 réalisé + +- **Thème** : + - clair par défaut ; les classes `dark:` suivent le bouton de thème ; + - animations (`tw-animate-css`) réellement importées ; + - couleurs shadcn branchées sur les variables ; + - libellés gris au contraste AA dans les deux thèmes ; + - fond plein pour le menu et l'en-tête (le flou ne servait à rien). +- **Composants partagés**, dans `src/components/ui/` : + - `PageHeader`, `Card`, `Field`, `StatCard` ; + - `Badge`, `DeltaBadge`, `StoreBadge`, `GammeBadge` ; + - `Tabs`, `Segmented`, `useUrlTab` ; + - `Select`, `SearchInput`, `Input` ; + - `EmptyState`, `ErrorState`, `Skeleton`, `PageSkeleton` ; + - `Tooltip`, `Terme` (glossaire) ; + - `toast` et `confirmer` (`feedback.tsx`) ; + - `Pagination`, `DataTable`. +- **Sources uniques** : + - `lib/magasins.ts` (noms des magasins) ; + - `lib/gammes.ts` (A à Z, avec leur sens et leurs couleurs) ; + - `lib/glossaire.ts` ; + - `lib/marge.ts` (seuils 40 / 25 %, ceux de la Grille) ; + - `lib/export-excel.ts` (export unique, via exceljs). +- **Navigation** : + - menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié par défaut, libellés en français ; + - en-tête : nom de la page, thème, menu du compte (vrai nom, hauteur des lignes de la Grille pour tous, déconnexion) ; + - la recherche globale, qui ne filtrait que la Grille, est retirée. +- **Historique** : nouvelle page `/historique` (sessions et exports), et redirection des anciennes adresses `/snapshots` et `/exports`. Les deux onglets sont visibles par tous : chacun n'y voit que ses propres enregistrements. +- **Chargements et erreurs** : + - squelettes de page au lieu de la fenêtre plein écran ; + - pages d'erreur (`error.tsx`) et « page introuvable » en français ; + - après connexion, arrivée sur l'Accueil, comme pour `/`. +- **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF. diff --git a/src/app/(dashboard)/analytics/loading.tsx b/src/app/(dashboard)/analytics/loading.tsx index 1f90c2f..1a73e79 100644 --- a/src/app/(dashboard)/analytics/loading.tsx +++ b/src/app/(dashboard)/analytics/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function AnalyticsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/commandes-auto/loading.tsx b/src/app/(dashboard)/commandes-auto/loading.tsx index 6d6405d..5116a85 100644 --- a/src/app/(dashboard)/commandes-auto/loading.tsx +++ b/src/app/(dashboard)/commandes-auto/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function CommandesAutoLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx index f3b9911..fad163f 100644 --- a/src/app/(dashboard)/dashboard/loading.tsx +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function DashboardLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..133e216 --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { RotateCcw } from "lucide-react"; +import { ErrorState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +/** + * Erreur inattendue dans une page : message compréhensible et bouton pour + * réessayer, au lieu de l'écran d'erreur générique de Next.js. Le menu et + * l'en-tête restent utilisables. + */ +export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("[page] erreur non rattrapée :", error); + }, [error]); + + return ( +
+ Réessayer} + /> +
+ ); +} diff --git a/src/app/(dashboard)/exports/loading.tsx b/src/app/(dashboard)/exports/loading.tsx deleted file mode 100644 index 96505c3..0000000 --- a/src/app/(dashboard)/exports/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingModal from "@/components/shared/loading-modal"; - -export default function ExportsLoading() { - return ; -} diff --git a/src/app/(dashboard)/exports/page.tsx b/src/app/(dashboard)/exports/page.tsx index b9bca62..151dddf 100644 --- a/src/app/(dashboard)/exports/page.tsx +++ b/src/app/(dashboard)/exports/page.tsx @@ -1,24 +1,6 @@ -import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; -import { FileDown } from "lucide-react"; +import { redirect } from "next/navigation"; +/** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function ExportsPage() { - return ( -
-
-
-
-
- -
-

Historique d'Arbitrage

-
-

- Retrouvez ici toutes vos sessions sauvegardées et exports réalisés. Vous pouvez restaurer une session pour reprendre votre travail ou supprimer les anciens enregistrements. -

-
-
- - -
- ); + redirect("/historique?vue=exports"); } diff --git a/src/app/(dashboard)/grid/loading.tsx b/src/app/(dashboard)/grid/loading.tsx index 9744063..bb7ba5f 100644 --- a/src/app/(dashboard)/grid/loading.tsx +++ b/src/app/(dashboard)/grid/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function GridLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/historique/page.tsx b/src/app/(dashboard)/historique/page.tsx new file mode 100644 index 0000000..2aefed6 --- /dev/null +++ b/src/app/(dashboard)/historique/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { History } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { HistoriqueTabs } from "./tabs"; + +export const metadata: Metadata = { title: "Historique" }; + +/** + * Historique des sessions de révision et des exports Excel. + * + * Remplace les pages « Snapshots » et « Exports », qui affichaient la même liste + * filtrée différemment (et dont la seconde s'intitulait « Historique d'arbitrage »). + * Chacun ne voit que ses propres enregistrements. + */ +export default async function HistoriquePage(props: { searchParams: Promise<{ vue?: string }> }) { + const { vue } = await props.searchParams; + return ( +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/historique/tabs.tsx b/src/app/(dashboard)/historique/tabs.tsx new file mode 100644 index 0000000..e048aca --- /dev/null +++ b/src/app/(dashboard)/historique/tabs.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { FileDown, Save } from "lucide-react"; +import { Tabs, useUrlTab } from "@/components/ui/tabs"; +import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; + +type Vue = "sessions" | "exports"; + +const ONGLETS = [ + { value: "sessions", label: "Sessions enregistrées", icon: Save }, + { value: "exports", label: "Exports Excel", icon: FileDown }, +] as const; + +export function HistoriqueTabs({ initial }: { initial: Vue }) { + const [vue, setVue] = useUrlTab("vue", initial); + return ( +
+ + {/* `key` : chaque onglet recharge sa propre liste. */} + +
+ ); +} diff --git a/src/app/(dashboard)/hit-parade/loading.tsx b/src/app/(dashboard)/hit-parade/loading.tsx index 276e208..bb7ba5f 100644 --- a/src/app/(dashboard)/hit-parade/loading.tsx +++ b/src/app/(dashboard)/hit-parade/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function HitParadeLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 6d334a4..929f166 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -7,7 +7,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
-
+
{children}
diff --git a/src/app/(dashboard)/not-found.tsx b/src/app/(dashboard)/not-found.tsx new file mode 100644 index 0000000..d586a26 --- /dev/null +++ b/src/app/(dashboard)/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { SearchX } from "lucide-react"; +import { EmptyState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+ Retour à l'accueil} + /> +
+ ); +} diff --git a/src/app/(dashboard)/produits/loading.tsx b/src/app/(dashboard)/produits/loading.tsx index 64ecef1..06df9fd 100644 --- a/src/app/(dashboard)/produits/loading.tsx +++ b/src/app/(dashboard)/produits/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function ProduitsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/publicites/loading.tsx b/src/app/(dashboard)/publicites/loading.tsx index 131b8b2..1a73e79 100644 --- a/src/app/(dashboard)/publicites/loading.tsx +++ b/src/app/(dashboard)/publicites/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function PublicitesLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/settings/loading.tsx b/src/app/(dashboard)/settings/loading.tsx index 7b00b11..06df9fd 100644 --- a/src/app/(dashboard)/settings/loading.tsx +++ b/src/app/(dashboard)/settings/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function SettingsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/snapshots/loading.tsx b/src/app/(dashboard)/snapshots/loading.tsx deleted file mode 100644 index 346c5ef..0000000 --- a/src/app/(dashboard)/snapshots/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingModal from "@/components/shared/loading-modal"; - -export default function SnapshotsLoading() { - return ; -} diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx index 5de7e02..5b117e0 100644 --- a/src/app/(dashboard)/snapshots/page.tsx +++ b/src/app/(dashboard)/snapshots/page.tsx @@ -1,24 +1,6 @@ -import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; -import { Camera } from "lucide-react"; +import { redirect } from "next/navigation"; +/** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function SnapshotsPage() { - return ( -
-
-
-
-
- -
-

Snapshots

-
-

- Historique de vos sessions d'arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique. -

-
-
- - -
- ); + redirect("/historique"); } diff --git a/src/app/(dashboard)/stock-negatif/loading.tsx b/src/app/(dashboard)/stock-negatif/loading.tsx index f934a03..1a73e79 100644 --- a/src/app/(dashboard)/stock-negatif/loading.tsx +++ b/src/app/(dashboard)/stock-negatif/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function StockNegatifLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 9946d08..93f9810 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,14 @@ @import "tailwindcss"; +/* Animations utilisées par les menus, fenêtres et notifications (animate-in, + fade-in, zoom-in-95…). Le paquet était installé mais jamais importé : ces + classes ne produisaient rien. */ +@import "tw-animate-css"; + +/* Les variantes `dark:` suivent le thème choisi dans l'application (classe + `.dark` posée par next-themes sur ), et non le réglage du système : + sans cette ligne, un poste en clair affichait des éléments clairs dans + l'application en sombre, et inversement. */ +@custom-variant dark (&:where(.dark, .dark *)); /* Police de l'app : Inter (chargée via dans le layout, fallback système). Définie ici pour que l'utilitaire `font-sans` et var(--font-sans) la prennent @@ -7,6 +17,30 @@ --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; } +/* Couleurs « shadcn » (bg-accent, text-muted-foreground, bg-primary…) branchées + sur les variables de l'application : elles n'étaient pas définies, et les + survols des menus déroulants, entre autres, ne se voyaient pas. */ +@theme inline { + --color-background: var(--bg-surface); + --color-foreground: var(--text-primary); + --color-card: var(--bg-surface); + --color-card-foreground: var(--text-primary); + --color-popover: var(--bg-surface); + --color-popover-foreground: var(--text-primary); + --color-primary: var(--brand-solid); + --color-primary-foreground: #ffffff; + --color-secondary: var(--bg-elevated); + --color-secondary-foreground: var(--text-primary); + --color-muted: var(--bg-elevated); + --color-muted-foreground: var(--text-secondary); + --color-accent: var(--bg-elevated); + --color-accent-foreground: var(--text-primary); + --color-destructive: var(--accent-error); + --color-border: var(--border); + --color-input: var(--border-strong); + --color-ring: var(--accent); +} + /* ═══════════════════════════════════════════ CollectFlow — Apple-inspired Design System @@ -42,8 +76,13 @@ /* Slate 200 */ --text-secondary: #94a3b8; /* Slate 400 */ - --text-muted: #64748b; - /* Slate 500 */ + --text-muted: #8494ab; + /* Entre Slate 400 et 500 : ≥ 4,5:1 sur toutes les surfaces sombres (AA). + L'ancien Slate 500 tombait à 3:1 sur --bg-elevated. */ + + /* Alias de variables utilisées par quelques écrans mais jamais définies. */ + --border-subtle: var(--border); + --surface-tertiary: var(--bg-elevated); --accent: #2dd4bf; /* emerald-ish, Apple-tuned */ @@ -77,8 +116,8 @@ --bg-surface: #ffffff; --bg-elevated: #f0f0f5; --bg-overlay: rgba(255, 255, 255, 0.82); - --border: rgba(0, 0, 0, 0.07); - --border-strong: rgba(0, 0, 0, 0.12); + --border: rgba(0, 0, 0, 0.10); + --border-strong: rgba(0, 0, 0, 0.16); /* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief » @@ -90,7 +129,9 @@ --text-primary: #1d1d1f; /* Apple dark */ --text-secondary: #515154; - --text-muted: #86868b; + /* ≥ 5:1 sur toutes les surfaces claires (AA). L'ancien #86868b, utilisé pour + la plupart des libellés, tombait à 3,2:1. */ + --text-muted: #636366; --accent: #0d9488; /* deeper teal in light */ @@ -187,11 +228,11 @@ padding: 20px; } -/* ── Glass panels (sidebar, header) ── */ +/* ── Panneaux fixes (menu, en-tête) ── + Fond plein : le flou d'arrière-plan ne floutait rien (rien ne défile sous ces + panneaux) mais coûtait un recalcul GPU à chaque image. */ .glass { - background: var(--bg-overlay); - backdrop-filter: saturate(180%) blur(20px); - -webkit-backdrop-filter: saturate(180%) blur(20px); + background: var(--bg-surface); } /* ── Apple border ── */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7fbdeef..adec33a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,8 @@ import "./globals.css"; import { Providers } from "./providers"; export const metadata: Metadata = { - title: "CollectFlow — Dashboard Décisionnel", - description: "Révision d'Assortiment Haute Performance", + title: { default: "CollectFlow", template: "%s · CollectFlow" }, + description: "Révision d'assortiment et suivi des ventes des magasins", }; export default function RootLayout({ diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..801eb98 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { SearchX } from "lucide-react"; +import { EmptyState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +/** Adresse inconnue (hors des pages de l'application). */ +export default function NotFound() { + return ( +
+ Retour à l'accueil} + className="max-w-lg" + /> +
+ ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 3e0acf0..74e9a73 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,12 +2,19 @@ import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from "@/components/shared/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { FeedbackHost } from "@/components/ui/feedback"; export function Providers({ children }: { children: React.ReactNode }) { return ( - + // La session n'est pas relue à chaque retour sur l'onglet : elle repartait + // sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. + - {children} + + {children} + + ); diff --git a/src/components/shared/header.tsx b/src/components/shared/header.tsx index d618e6f..3a9bc0d 100644 --- a/src/components/shared/header.tsx +++ b/src/components/shared/header.tsx @@ -1,73 +1,116 @@ "use client"; -import { Search, X, Sun, Moon } from "lucide-react"; +import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; +import { signOut, useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { pageCourante } from "@/components/shared/navigation"; +type Densite = "compact" | "normal" | "comfortable"; + +/** + * En-tête : nom de la page en cours, choix du thème et menu du compte. + * + * La recherche qui s'y trouvait ne filtrait que la Grille (même depuis les autres + * pages, et en silence) : elle est retirée, la Grille garde la sienne. Le nom + * affiché est celui de l'utilisateur connecté (il était écrit en dur). + */ export function Header() { + const pathname = usePathname(); const { resolvedTheme, setTheme } = useTheme(); - const setFilter = useGridStore((s) => s.setFilter); - const search = useGridStore((s) => s.filters.search); - const isDark = resolvedTheme === "dark"; + const { data: session } = useSession(); + const displayDensity = useGridStore((s) => s.displayDensity); + const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + // Le thème n'est connu qu'au montage (lu dans le navigateur). + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsMounted(true); + }, []); + + const isDark = isMounted && resolvedTheme === "dark"; + const page = pageCourante(pathname); + const nom = session?.user?.name ?? ""; + const role = (session?.user as { role?: string } | undefined)?.role === "admin" ? "Administrateur" : "Utilisateur"; + const initiale = nom.trim().charAt(0).toUpperCase() || "?"; return ( -
- {/* Search */} -
- - setFilter("search", e.target.value)} - className="apple-input pr-8 h-8 text-[13px]" - style={{ paddingLeft: "32px" }} - /> - {search && ( - +
+
+ {page && ( +

+ {page.label} +

)}
- {/* Right controls */}
- {/* Theme toggle */} - {/* Divider */} -
- - {/* User */} -
-
- M -
- - Michael - -
+ + + + + + + {nom || "Compte"} + {role} + + + + Hauteur des lignes de la Grille + + setDisplayDensity(v as Densite)} + > + Compacte + Normale + Aérée + + + { + await signOut({ redirect: false }); + window.location.href = "/login"; + }} + > + Se déconnecter + + +
); diff --git a/src/components/shared/navigation.ts b/src/components/shared/navigation.ts new file mode 100644 index 0000000..425d918 --- /dev/null +++ b/src/components/shared/navigation.ts @@ -0,0 +1,77 @@ +import { + CalendarClock, + CalendarRange, + History, + House, + LayoutGrid, + Megaphone, + PackageMinus, + PackageSearch, + Settings, + ShoppingCart, + Trophy, + type LucideIcon, +} from "lucide-react"; + +export interface NavItem { + label: string; + href: string; + icon: LucideIcon; + /** Phrase d'aide affichée au survol du menu replié. */ + description: string; + adminOnly?: boolean; +} + +export interface NavGroup { + title: string; + items: NavItem[]; +} + +/** + * Menu principal, regroupé par usage. Les libellés disent ce que fait la page, + * en français (« Dashboard », « Analytics », « Snapshots » ont disparu). + * Les adresses ne changent pas : favoris et liens existants restent valables. + */ +export const NAV_GROUPS: NavGroup[] = [ + { + title: "Au quotidien", + items: [ + { label: "Accueil", href: "/dashboard", icon: House, description: "Les ventes d'hier et les meilleurs produits" }, + { label: "Révision d'assortiment", href: "/grid", icon: LayoutGrid, description: "Choisir la gamme de chaque produit d'un fournisseur" }, + { label: "Recherche produit", href: "/produits", icon: PackageSearch, description: "Trouver un produit et voir ses ventes dans le réseau" }, + { label: "Stocks à surveiller", href: "/stock-negatif", icon: PackageMinus, description: "Stocks négatifs et produits qui ne se vendent pas" }, + { label: "Commandes fournisseurs", href: "/commandes-auto", icon: ShoppingCart, description: "Commandes automatiques et rythme de commande" }, + ], + }, + { + title: "Analyses", + items: [ + { label: "Meilleures ventes", href: "/hit-parade", icon: Trophy, description: "Classement des produits les plus vendus sur une période" }, + { label: "Ventes par mois", href: "/analytics", icon: CalendarRange, description: "Chiffre d'affaires d'un mois comparé à l'an dernier" }, + { label: "Publicités", href: "/publicites", icon: Megaphone, description: "Résultats des opérations publicitaires" }, + ], + }, + { + title: "Suivi", + items: [ + { label: "Historique", href: "/historique", icon: History, description: "Sessions de révision enregistrées et exports" }, + ], + }, + { + title: "Administration", + items: [ + { label: "Synchronisation", href: "/admin/synchronisation", icon: CalendarClock, description: "Mise à jour nocturne des données", adminOnly: true }, + { label: "Paramètres", href: "/settings", icon: Settings, description: "Connexions, utilisateurs et clés d'accès", adminOnly: true }, + ], + }, +]; + +/** Entrée du menu correspondant à l'adresse courante (pour le titre de l'en-tête). */ +export function pageCourante(pathname: string): NavItem | undefined { + for (const g of NAV_GROUPS) { + for (const item of g.items) { + if (pathname === item.href || pathname.startsWith(`${item.href}/`)) return item; + } + } + return undefined; +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 613da03..976ab12 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,161 +1,139 @@ "use client"; -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 { ChevronLeft, ChevronRight, Package } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; import { cn } from "@/lib/utils"; import { useGridStore } from "@/features/grid/store/use-grid-store"; -import { useEffect, useState } from "react"; -import { useSession, signOut } from "next-auth/react"; +import { NAV_GROUPS } from "@/components/shared/navigation"; +import { Tooltip } from "@/components/ui/tooltip"; -const NAV_ITEMS = [ - { icon: BarChart2, label: "Dashboard", href: "/dashboard" }, - { icon: LayoutGrid, label: "Grille", href: "/grid" }, - { icon: PackageSearch, label: "Produits", href: "/produits" }, - { icon: TrendingUp, label: "Analytics", href: "/analytics" }, - { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, - { icon: Megaphone, label: "Publicités", href: "/publicites" }, - { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" }, - { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" }, - { icon: Camera, label: "Snapshots", href: "/snapshots" }, - { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, - { icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true }, - { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, -]; +const CLE_STOCKAGE = "sidebar_expanded"; +/** + * Menu principal : groupé par usage, déplié par défaut (libellés visibles). + * Le compte utilisateur et la déconnexion sont dans l'en-tête. + */ export function Sidebar() { const pathname = usePathname(); const { data: session } = useSession(); const activeGridQuery = useGridStore((s) => s.activeGridQuery); const [isMounted, setIsMounted] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); useEffect(() => { + // `isMounted` évite un écart d'hydratation (préférence lue dans le navigateur). + // eslint-disable-next-line react-hooks/set-state-in-effect setIsMounted(true); - // Load expanded state from local storage - const stored = localStorage.getItem("sidebar_expanded"); - if (stored === "true") setIsExpanded(true); + try { + // Seul un repli explicite est mémorisé : par défaut, le menu est déplié. + if (localStorage.getItem(CLE_STOCKAGE) === "false") setIsExpanded(false); + } catch { + /* stockage indisponible : on garde le menu déplié */ + } }, []); const toggleSidebar = () => { const next = !isExpanded; setIsExpanded(next); - localStorage.setItem("sidebar_expanded", String(next)); + try { + localStorage.setItem(CLE_STOCKAGE, String(next)); + } catch { + /* préférence non mémorisée, sans conséquence */ + } }; - const userRole = (session?.user as any)?.role; - const filteredItems = NAV_ITEMS.filter(item => !item.adminOnly || userRole === "admin"); + const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; + const groupes = NAV_GROUPS + .map((g) => ({ ...g, items: g.items.filter((i) => !i.adminOnly || isAdmin) })) + .filter((g) => g.items.length > 0); return ( ); } diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx index a1eb978..b7556ec 100644 --- a/src/components/shared/theme-provider.tsx +++ b/src/components/shared/theme-provider.tsx @@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { return ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..8355752 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { TrendingDown, TrendingUp, Minus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { nomMagasin } from "@/lib/magasins"; +import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes"; +import { fmtDecimal1 } from "@/lib/format"; + +export type Ton = "neutre" | "accent" | "succes" | "alerte" | "erreur" | "info"; + +const TONS: Record = { + neutre: "bg-[var(--bg-elevated)] text-[var(--text-secondary)] border-[var(--border)]", + accent: "bg-[var(--accent-bg)] text-[var(--accent)] border-[var(--accent-border)]", + succes: "bg-[var(--accent-success-bg)] text-[var(--accent-success)] border-[var(--accent-success)]/30", + alerte: "bg-[var(--accent-warning-bg)] text-[var(--accent-warning)] border-[var(--accent-warning)]/30", + erreur: "bg-[var(--accent-error-bg)] text-[var(--accent-error)] border-[var(--accent-error)]/30", + info: "bg-[var(--accent-bg)] text-[var(--viz-1)] border-[var(--border)]", +}; + +export function Badge({ ton = "neutre", children, className, title }: { + ton?: Ton; + children: ReactNode; + className?: string; + title?: string; +}) { + return ( + + {children} + + ); +} + +/** + * Évolution en pourcentage (vs N-1) : flèche, signe et couleur. `null` (pas de + * base de comparaison) s'affiche « — » plutôt qu'un faux 0 %. + */ +export function DeltaBadge({ pct, className }: { pct: number | null | undefined; className?: string }) { + if (pct == null || !Number.isFinite(pct)) { + return —; + } + const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre"; + const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus; + return ( + + + {pct > 0 ? "+" : ""}{fmtDecimal1(pct)} % + + ); +} + +/** Nom du magasin, jamais son code brut. */ +export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) { + return {nomMagasin(code, { court })}; +} + +/** Pastille de gamme avec, au besoin, son nom (« A — Cœur »). */ +export function GammeBadge({ code, avecNom = false }: { code: string | null | undefined; avecNom?: boolean }) { + const g = trouverGamme(code); + return ( + + {g ? g.code : "—"} + {avecNom && {g ? g.nom : "Sans gamme"}} + + ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..17e9869 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,45 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** Conteneur de base : fond, bordure et ombre du thème. */ +export function Card({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +interface CardHeaderProps { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + className?: string; +} + +export function CardHeader({ title, description, actions, className }: CardHeaderProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +export function CardContent({ className, ...props }: HTMLAttributes) { + return
; +} + +/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */ +export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 0000000..cac422f --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,359 @@ +"use client"; + +/** + * Tableau de données commun : recherche, filtres, tri par colonne, pagination, + * export, état vide et ligne de totaux — identiques sur toutes les pages. + * + * Né du tableau de la page Stocks, qui avait déjà tout cela ; les autres pages + * en réimplémentaient chacune une partie (13 tableaux faits à la main). + */ + +import { useDeferredValue, useMemo, useState, type ReactNode } from "react"; +import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { SearchInput, Select } from "@/components/ui/form-controls"; +import { Pagination } from "@/components/ui/pagination"; +import { EmptyState } from "@/components/ui/states"; +import { Tooltip } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/feedback"; + +type Sens = "asc" | "desc"; + +export interface DataColumn { + id: string; + header: ReactNode; + /** Explication de la colonne, en infobulle sur l'en-tête. */ + hint?: ReactNode; + align?: "left" | "right" | "center"; + /** Valeur de tri ; sans elle, la colonne n'est pas triable. */ + sortValue?: (row: T) => string | number | null | undefined; + cell: (row: T) => ReactNode; + /** Cellule de la ligne de totaux (cf. `showFooter`). */ + footer?: (rows: readonly T[]) => ReactNode; + className?: string; + headerClassName?: string; +} + +export interface DataFilter { + id: string; + label: string; + valueOf: (row: T) => string | null | undefined; + /** Libellé affiché pour une valeur (ex. code magasin → nom). */ + optionLabel?: (value: string) => string; + /** Libellé de l'option « tout » (par défaut « Tous »). */ + allLabel?: string; + /** Ordre des options : alphabétique (défaut) ou décroissant (années…). */ + order?: Sens; +} + +interface DataTableProps { + rows: readonly T[]; + columns: readonly DataColumn[]; + rowKey: (row: T, index: number) => string; + /** Champs parcourus par la recherche ; sans eux, pas de champ de recherche. */ + searchIn?: (row: T) => (string | number | null | undefined)[]; + searchPlaceholder?: string; + filters?: readonly DataFilter[]; + /** Lignes par page ; 0 = tout afficher. */ + pageSize?: number; + /** Nom des lignes comptées : « articles », « fournisseurs »… */ + unite?: string; + emptyTitle?: ReactNode; + emptyDescription?: ReactNode; + /** Export des lignes filtrées et triées (toutes, pas seulement la page affichée). */ + onExport?: (rows: readonly T[]) => Promise | void; + exportLabel?: string; + /** Contrôles supplémentaires, placés avant la recherche. */ + toolbar?: ReactNode; + /** Affiche la ligne de totaux (colonnes avec `footer`). */ + showFooter?: boolean; + initialSort?: { id: string; dir: Sens }; + onRowClick?: (row: T) => void; + /** Hauteur maximale de la zone défilante (en-tête collant). */ + maxHeight?: string; + className?: string; +} + +const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" }); + +function comparer(a: string | number | null | undefined, b: string | number | null | undefined): number { + const videA = a === null || a === undefined || a === ""; + const videB = b === null || b === undefined || b === ""; + if (videA && videB) return 0; + if (videA) return 1; // valeurs absentes toujours en fin de liste + if (videB) return -1; + if (typeof a === "number" && typeof b === "number") return a - b; + return collator.compare(String(a), String(b)); +} + +export function DataTable({ + rows, + columns, + rowKey, + searchIn, + searchPlaceholder = "Rechercher…", + filters = [], + pageSize = 50, + unite = "lignes", + emptyTitle = "Aucun résultat", + emptyDescription, + onExport, + exportLabel = "Exporter en Excel", + toolbar, + showFooter = false, + initialSort, + onRowClick, + maxHeight = "70vh", + className, +}: DataTableProps) { + const [recherche, setRecherche] = useState(""); + const rechercheDifferee = useDeferredValue(recherche); + const [valeursFiltres, setValeursFiltres] = useState>({}); + const [tri, setTri] = useState<{ id: string; dir: Sens } | null>(initialSort ?? null); + const [page, setPage] = useState(1); + const [exportEnCours, setExportEnCours] = useState(false); + + const optionsFiltres = useMemo(() => { + const out: Record = {}; + for (const f of filters) { + const valeurs = new Set(); + for (const r of rows) { + const v = f.valueOf(r); + if (v) valeurs.add(v); + } + const triees = [...valeurs].sort((a, b) => (f.order === "desc" ? -1 : 1) * collator.compare(a, b)); + out[f.id] = triees.map((v) => ({ value: v, label: f.optionLabel ? f.optionLabel(v) : v })); + } + return out; + }, [filters, rows]); + + const filtrees = useMemo(() => { + const q = rechercheDifferee.trim().toLowerCase(); + const actifs = filters.filter((f) => valeursFiltres[f.id]); + if (!q && actifs.length === 0) return rows; + return rows.filter((r) => { + for (const f of actifs) if (f.valueOf(r) !== valeursFiltres[f.id]) return false; + if (q && searchIn) { + return searchIn(r).some((v) => v !== null && v !== undefined && String(v).toLowerCase().includes(q)); + } + return true; + }); + }, [rows, filters, valeursFiltres, rechercheDifferee, searchIn]); + + const triees = useMemo(() => { + if (!tri) return filtrees; + const col = columns.find((c) => c.id === tri.id); + if (!col?.sortValue) return filtrees; + const valeur = col.sortValue; + const sens = tri.dir === "asc" ? 1 : -1; + return [...filtrees].sort((a, b) => { + const va = valeur(a); + const vb = valeur(b); + const vide = va === null || va === undefined || va === "" || vb === null || vb === undefined || vb === ""; + // Les valeurs absentes restent en fin de liste dans les deux sens. + return vide ? comparer(va, vb) : comparer(va, vb) * sens; + }); + }, [filtrees, tri, columns]); + + // Tout changement de filtre, de recherche ou de tri ramène à la page 1 + // (ajusté pendant le rendu, sans effet ni rendu supplémentaire). + const [jeuPagine, setJeuPagine] = useState(triees); + if (jeuPagine !== triees) { + setJeuPagine(triees); + setPage(1); + } + + const total = triees.length; + const pagine = pageSize > 0; + const totalPages = pagine ? Math.max(1, Math.ceil(total / pageSize)) : 1; + const pageCourante = Math.min(page, totalPages); + const lignesAffichees = useMemo( + () => (pagine ? triees.slice((pageCourante - 1) * pageSize, pageCourante * pageSize) : triees), + [triees, pagine, pageCourante, pageSize], + ); + + const filtreActif = recherche !== "" || Object.values(valeursFiltres).some(Boolean); + + function basculerTri(id: string) { + // Premier clic : les plus grands chiffres d'abord, mais l'ordre A→Z pour du texte. + const col = columns.find((c) => c.id === id); + const exemple = rows.length > 0 ? col?.sortValue?.(rows[0]) : undefined; + const sensInitial: Sens = typeof exemple === "number" ? "desc" : "asc"; + setTri((t) => (t?.id === id ? { id, dir: t.dir === "asc" ? "desc" : "asc" } : { id, dir: sensInitial })); + } + + async function exporter() { + if (!onExport || exportEnCours) return; + setExportEnCours(true); + try { + await onExport(triees); + } catch (e) { + toast.erreur(`L'export a échoué : ${e instanceof Error ? e.message : String(e)}`); + } finally { + setExportEnCours(false); + } + } + + const alignement = (a?: "left" | "right" | "center") => + a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left"; + + return ( +
+ {/* Barre d'outils */} +
+
+ {toolbar} + {searchIn && ( + + )} + {filters.map((f) => ( + onChange(e.target.value)} + className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)} + {...props} + > + {placeholder !== undefined && } + {options.map((o) => ( + + ))} + + ); + if (!label) return select; + return ( +
+ + {select} +
+ ); +} + +interface SearchInputProps extends Omit, "onChange" | "value"> { + value: string; + onChange: (value: string) => void; +} + +/** Champ de recherche avec loupe et bouton d'effacement. */ +export function SearchInput({ value, onChange, className, placeholder = "Rechercher…", ...props }: SearchInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")} + {...props} + /> + {value && ( + + )} +
+ ); +} + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ; +} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx new file mode 100644 index 0000000..9003833 --- /dev/null +++ b/src/components/ui/page-header.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PageHeaderProps { + title: ReactNode; + /** Une phrase : à quoi sert la page, en langage courant. */ + description?: ReactNode; + icon?: LucideIcon; + /** Boutons ou filtres alignés à droite du titre. */ + actions?: ReactNode; + className?: string; +} + +/** + * En-tête commun à toutes les pages : même taille de titre, même place pour + * l'explication et pour les actions. Il y en avait sept variantes. + */ +export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) { + return ( +
+
+ {Icon && ( +
+ +
+ )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..9ed0b15 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +interface PaginationProps { + page: number; + totalPages: number; + total: number; + pageSize: number; + onChange: (page: number) => void; + /** Nom des éléments comptés (« articles », « publicités »…). */ + unite?: string; +} + +/** Pagination commune : « 51 à 100 sur 1 234 articles » et quatre boutons. */ +export function Pagination({ page, totalPages, total, pageSize, onChange, unite = "lignes" }: PaginationProps) { + if (total === 0 || totalPages <= 1) return null; + const debut = (page - 1) * pageSize + 1; + const fin = Math.min(page * pageSize, total); + const aller = (p: number) => onChange(Math.min(Math.max(1, p), totalPages)); + const bouton = + "rounded-lg border p-1.5 border-[var(--border-strong)] bg-[var(--bg-surface)] text-[var(--text-secondary)] " + + "hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"; + + return ( + + ); +} diff --git a/src/components/ui/stat-card.tsx b/src/components/ui/stat-card.tsx new file mode 100644 index 0000000..d3c6424 --- /dev/null +++ b/src/components/ui/stat-card.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { DeltaBadge } from "@/components/ui/badge"; + +interface StatCardProps { + label: ReactNode; + value: ReactNode; + /** Précision sous la valeur (« dont 12 en promo », « hier »…). */ + hint?: ReactNode; + /** Évolution en % à afficher en pastille (vs N-1). */ + delta?: number | null; + deltaLabel?: ReactNode; + icon?: LucideIcon; + className?: string; +} + +/** Indicateur chiffré : libellé lisible, valeur en grand, évolution. */ +export function StatCard({ label, value, hint, delta, deltaLabel = "vs N-1", icon: Icon, className }: StatCardProps) { + return ( +
+
+ {label} + {Icon && } +
+
{value}
+ {(delta !== undefined || hint) && ( +
+ {delta !== undefined && ( + <> + + {deltaLabel} + + )} + {hint && {hint}} +
+ )} +
+ ); +} diff --git a/src/components/ui/states.tsx b/src/components/ui/states.tsx new file mode 100644 index 0000000..29f1688 --- /dev/null +++ b/src/components/ui/states.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { AlertTriangle, Inbox, type LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface EmptyStateProps { + title: ReactNode; + description?: ReactNode; + icon?: LucideIcon; + action?: ReactNode; + className?: string; +} + +/** Rien à afficher : dire pourquoi, et quoi faire. */ +export function EmptyState({ title, description, icon: Icon = Inbox, action, className }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +interface ErrorStateProps { + title?: ReactNode; + /** Explication pour l'utilisateur ; le détail technique va dans `detail`. */ + description?: ReactNode; + /** Message technique, replié par défaut. */ + detail?: string; + action?: ReactNode; + className?: string; +} + +/** Un chargement a échoué : message clair, détail technique replié, action de reprise. */ +export function ErrorState({ + title = "Les données n'ont pas pu être chargées", + description = "Réessayez dans un instant. Si le problème persiste, prévenez un administrateur.", + detail, + action, + className, +}: ErrorStateProps) { + return ( +
+ +

{title}

+

{description}

+ {detail && ( +
+ Détail technique +
{detail}
+
+ )} + {action &&
{action}
} +
+ ); +} + +export function Skeleton({ className }: { className?: string }) { + return
; +} + +/** + * Silhouette d'une page pendant son chargement : la mise en page reste visible + * et rien ne bloque l'écran (contrairement à l'ancienne fenêtre plein écran). + */ +export function PageSkeleton({ cards = 0, rows = 8 }: { cards?: number; rows?: number }) { + return ( +
+
+ +
+ + +
+
+ {cards > 0 && ( +
+ {Array.from({ length: cards }, (_, i) => )} +
+ )} +
+ + {Array.from({ length: rows }, (_, i) => )} +
+
+ ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..a74d3b3 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface TabItem { + value: V; + label: ReactNode; + /** Compteur affiché à droite du libellé. */ + count?: number; + /** Pastille d'alerte (rouge) au lieu d'un compteur neutre. */ + alert?: boolean; + icon?: LucideIcon; +} + +interface TabsProps { + items: readonly TabItem[]; + value: V; + onChange: (value: V) => void; + disabled?: boolean; + className?: string; +} + +/** + * Onglets soulignés, identiques sur toutes les pages. L'appelant décide de la + * synchronisation avec l'URL (cf. `useUrlTab`) : un onglet doit pouvoir être + * partagé par lien et survivre au rechargement. + */ +export function Tabs({ items, value, onChange, disabled, className }: TabsProps) { + return ( +
+ {items.map((t) => { + const actif = t.value === value; + const Icone = t.icon; + return ( + + ); + })} +
+ ); +} + +/** + * Segment de choix court (2 à 4 options), pour les bascules du type + * « Fournisseur / Nomenclature » ou « Tous / Frouard / Houdemont ». + */ +export function Segmented({ items, value, onChange, disabled, className }: TabsProps) { + return ( +
+ {items.map((t) => { + const actif = t.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * Onglet actif reflété dans l'URL (`?param=valeur`) sans navigation serveur : + * le lien se partage et survit au rechargement, et changer d'onglet ne relance + * aucune requête. `initial` vient de la page serveur (searchParams). + */ +export function useUrlTab(param: string, initial: V): [V, (value: V) => void] { + const [value, setValue] = useState(initial); + const change = (next: V) => { + setValue(next); + const url = new URL(window.location.href); + url.searchParams.set(param, next); + window.history.replaceState(window.history.state, "", url.toString()); + }; + return [value, change]; +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..c8ddbdd --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { Info } from "lucide-react"; +import { GLOSSAIRE, type CleGlossaire } from "@/lib/glossaire"; +import { cn } from "@/lib/utils"; + +/** À placer une fois en haut de l'arbre (cf. app/providers.tsx). */ +export function TooltipProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +interface TooltipProps { + content: ReactNode; + children: ReactNode; + side?: "top" | "right" | "bottom" | "left"; +} + +/** Infobulle accessible (survol et focus clavier), lisible dans les deux thèmes. */ +export function Tooltip({ content, children, side = "top" }: TooltipProps) { + return ( + + {children} + + + {content} + + + + ); +} + +interface TermeProps { + /** Entrée du glossaire à expliquer. */ + id: CleGlossaire; + /** Texte affiché ; par défaut le libellé du glossaire. */ + children?: ReactNode; + className?: string; +} + +/** + * Terme métier avec sa définition au survol : un petit « i » signale qu'une + * explication existe, sans encombrer la lecture. + */ +export function Terme({ id, children, className }: TermeProps) { + const entree = GLOSSAIRE[id]; + return ( + + {entree.terme} + {entree.definition} + + } + > + + {children ?? entree.terme} + + + + ); +} diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts index df29b2f..9b514f9 100644 --- a/src/features/auth/api/login-action.ts +++ b/src/features/auth/api/login-action.ts @@ -11,7 +11,7 @@ export async function loginAction(prevState: string | undefined, formData: FormD await signIn("credentials", { username: formData.get("username"), password: formData.get("password"), - redirectTo: "/grid" + redirectTo: "/dashboard" }); } catch (error) { if (error instanceof AuthError) { diff --git a/src/lib/export-excel.ts b/src/lib/export-excel.ts new file mode 100644 index 0000000..9f32259 --- /dev/null +++ b/src/lib/export-excel.ts @@ -0,0 +1,53 @@ +/** + * Export Excel côté navigateur, commun aux pages (Stocks, Meilleures ventes, + * Ventes par mois…). La bibliothèque n'est chargée qu'au moment de l'export. + * + * Il y avait trois mécanismes et deux bibliothèques (xlsx, exceljs) : une seule + * subsiste, avec la même mise en forme d'en-tête partout. + */ + +export type CelluleExcel = string | number | null | undefined; + +interface ExportExcel { + /** Nom de l'onglet du classeur. */ + feuille: string; + /** Nom du fichier, sans extension ni date (ajoutées automatiquement). */ + fichier: string; + entetes: string[]; + /** Largeurs de colonnes, en caractères. */ + largeurs?: number[]; + lignes: CelluleExcel[][]; + /** Ligne de totaux, mise en gras. */ + totaux?: CelluleExcel[]; +} + +export async function telechargerExcel({ feuille, fichier, entetes, largeurs, lignes, totaux }: ExportExcel): Promise { + const { Workbook } = await import("exceljs"); + const classeur = new Workbook(); + classeur.creator = "CollectFlow"; + classeur.created = new Date(); + const ws = classeur.addWorksheet(feuille.slice(0, 31)); // limite Excel + + ws.addRow(entetes); + const entete = ws.getRow(1); + entete.height = 22; + entete.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.views = [{ state: "frozen", ySplit: 1 }]; + + for (const l of lignes) ws.addRow(l.map((v) => v ?? "")); + if (totaux) ws.addRow(totaux.map((v) => v ?? "")).font = { bold: true }; + if (largeurs) ws.columns = largeurs.map((width) => ({ width })); + + const buffer = await classeur.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 = `${fichier}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/gammes.ts b/src/lib/gammes.ts new file mode 100644 index 0000000..81e1fb3 --- /dev/null +++ b/src/lib/gammes.ts @@ -0,0 +1,66 @@ +/** + * Les gammes d'assortiment : code, nom et couleurs — source unique. + * + * Les couleurs étaient définies trois fois (liste déroulante, filtre, actions + * groupées) et le sens des lettres n'apparaissait que dans la barre d'actions + * groupées : ailleurs, l'utilisateur ne voyait que « A », « B »… + */ + +export type CodeGamme = "A" | "B" | "C" | "Y" | "Z"; + +export interface Gamme { + code: CodeGamme; + /** Nom court : « Cœur », « Sortie »… */ + nom: string; + /** Phrase d'explication pour les infobulles. */ + description: string; + /** Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre. */ + classes: string; +} + +export const GAMMES: readonly Gamme[] = [ + { + code: "A", + nom: "Cœur", + description: "Produit permanent, au cœur de l'assortiment.", + classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300", + }, + { + code: "B", + nom: "Complémentaire", + description: "Produit qui complète l'offre du rayon.", + classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300", + }, + { + code: "C", + nom: "Saisonnier", + description: "Produit présent selon la saison.", + classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300", + }, + { + code: "Y", + nom: "En veille", + description: "Produit mis en attente : il n'est plus commandé pour l'instant.", + classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300", + }, + { + code: "Z", + nom: "Sortie", + description: "Produit à retirer de l'assortiment.", + classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300", + }, +]; + +/** Pastille d'un produit sans gamme. */ +export const CLASSES_SANS_GAMME = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300"; + +export function trouverGamme(code: string | null | undefined): Gamme | undefined { + if (!code) return undefined; + return GAMMES.find((g) => g.code === code.trim()); +} + +/** « A — Cœur », ou « Sans gamme ». */ +export function libelleGamme(code: string | null | undefined): string { + const g = trouverGamme(code); + return g ? `${g.code} — ${g.nom}` : "Sans gamme"; +} diff --git a/src/lib/glossaire.ts b/src/lib/glossaire.ts new file mode 100644 index 0000000..2e62ecb --- /dev/null +++ b/src/lib/glossaire.ts @@ -0,0 +1,97 @@ +/** + * Glossaire des termes métier affichés dans l'application. + * + * Une seule définition par terme, reprise par les infobulles (``) : un + * utilisateur occasionnel ne doit pas avoir à deviner ce que signifient + * « Franco », « PRMP » ou « Réseau ». + */ + +import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; + +export interface EntreeGlossaire { + /** Libellé affiché. */ + terme: string; + /** Explication en une ou deux phrases. */ + definition: string; +} + +export const GLOSSAIRE = { + gamme: { + terme: "Gamme", + definition: "Place du produit dans l'assortiment du magasin, de A (cœur de gamme) à Z (sortie).", + }, + gammeInit: { + terme: "Gamme actuelle", + definition: "Gamme enregistrée aujourd'hui dans FF, avant vos modifications.", + }, + reseau: { + terme: "Réseau", + definition: `Les ${NB_MAGASINS_RESEAU} magasins du réseau La Foir'Fouille (données Qlik).`, + }, + nosMagasins: { + terme: "Nos magasins", + definition: "Frouard (Nancy) et Houdemont.", + }, + presenceReseau: { + terme: "Magasins vendeurs", + definition: `Nombre de magasins du réseau (sur ${NB_MAGASINS_RESEAU}) qui ont vendu ce produit sur la période.`, + }, + caParMagasin: { + terme: "CA par magasin", + definition: "Chiffre d'affaires moyen d'un magasin du réseau qui vend ce produit.", + }, + franco: { + terme: "Franco", + definition: "Montant de commande à atteindre pour que le fournisseur livre sans frais de port.", + }, + commandeAuto: { + terme: "Commande automatique", + definition: "Proposition de commande calculée par FF à partir des ventes et du stock.", + }, + cadencier: { + terme: "Cadencier", + definition: "Rythme de commande choisi pour un fournisseur (toutes les X semaines) : l'alerte se déclenche quand l'échéance approche.", + }, + pcb: { + terme: "PCB", + definition: "« Par combien » : nombre d'unités dans un colis du fournisseur.", + }, + prmp: { + terme: "PRMP", + definition: "Prix de revient moyen pondéré : coût d'achat moyen d'une unité en stock.", + }, + gtin: { + terme: "Code-barres", + definition: "Code EAN / GTIN imprimé sur l'emballage du produit.", + }, + codeInterne: { + terme: "Code article", + definition: "Code interne de l'article dans FF.", + }, + codeCentrale: { + terme: "Code centrale", + definition: "Code de l'article à la centrale d'achat, commun à tous les magasins du réseau.", + }, + n1: { + terme: "N-1", + definition: "Même période l'année précédente.", + }, + marge: { + terme: "Marge", + definition: "Chiffre d'affaires moins le coût d'achat. Le taux de marge est la marge divisée par le chiffre d'affaires.", + }, + stockNegatif: { + terme: "Stock négatif", + definition: "Le stock calculé est inférieur à zéro : des ventes ont été enregistrées sans l'entrée de marchandise correspondante.", + }, + tauxSortie: { + terme: "Taux de sortie", + definition: "Part du stock de l'opération vendue pendant la publicité.", + }, + partCaTotal: { + terme: "Part du CA total", + definition: "Poids des ventes de l'opération dans le chiffre d'affaires total du magasin sur la même période.", + }, +} satisfies Record; + +export type CleGlossaire = keyof typeof GLOSSAIRE; diff --git a/src/lib/magasins.ts b/src/lib/magasins.ts new file mode 100644 index 0000000..04f5385 --- /dev/null +++ b/src/lib/magasins.ts @@ -0,0 +1,48 @@ +/** + * Les magasins suivis par l'application — source unique des noms affichés. + * + * Chaque page avait sa propre version (« 292 — Frouard / Nancy », « Frouard », + * « Nancy », « F », « Site 292 »…) : un même magasin portait jusqu'à huit noms. + * Toute page qui affiche un magasin passe désormais par `nomMagasin()`. + */ + +export interface Magasin { + /** Code FF du site (clé de toutes les requêtes). */ + code: string; + /** Nom complet, celui affiché par défaut. */ + nom: string; + /** Nom court, pour les en-têtes de colonnes étroites. */ + court: string; + /** Initiale, pour les pastilles. */ + initiale: string; +} + +export const MAGASINS: readonly Magasin[] = [ + { code: "292", nom: "Frouard (Nancy)", court: "Frouard", initiale: "F" }, + { code: "579", nom: "Houdemont", court: "Houdemont", initiale: "H" }, +]; + +/** Code utilisé par la Grille et l'API pour « tous nos magasins ». */ +export const CODE_TOUS_MAGASINS = "TOTAL"; +/** Libellé de l'ensemble de nos magasins (à ne pas confondre avec le réseau). */ +export const LIBELLE_TOUS_MAGASINS = "Nos 2 magasins"; + +/** Anciens codes encore présents dans certaines réponses de l'API FF. */ +const ALIAS: Record = { F01: "292", F02: "579" }; + +export function trouverMagasin(code: string | null | undefined): Magasin | undefined { + if (!code) return undefined; + const c = String(code).trim(); + return MAGASINS.find((m) => m.code === (ALIAS[c] ?? c)); +} + +/** + * Nom lisible d'un magasin. `TOTAL` (ou vide) donne « Nos 2 magasins » ; un code + * inconnu est renvoyé tel quel plutôt que masqué. + */ +export function nomMagasin(code: string | null | undefined, options: { court?: boolean } = {}): string { + if (!code || code === CODE_TOUS_MAGASINS) return LIBELLE_TOUS_MAGASINS; + const m = trouverMagasin(code); + if (!m) return String(code); + return options.court ? m.court : m.nom; +} diff --git a/src/lib/marge.ts b/src/lib/marge.ts new file mode 100644 index 0000000..3eb5ca2 --- /dev/null +++ b/src/lib/marge.ts @@ -0,0 +1,33 @@ +/** + * Seuils de couleur des taux de marge — une seule règle pour toute l'application. + * + * Le tableau de bord colorait à 25 / 15 %, la fiche produit à 40 / 25 %, la + * Grille à 30 / 15 % pour le réseau : un même taux changeait de couleur d'une + * page à l'autre. On retient la règle de la Grille (colonne Marge), l'écran + * le plus utilisé : 40 % et 25 %. + */ + +export const SEUILS_MARGE = { + /** À partir de ce taux : vert. */ + bon: 40, + /** À partir de ce taux : orange ; en dessous : rouge. */ + moyen: 25, +} as const; + +export type NiveauMarge = "bon" | "moyen" | "faible"; + +export function niveauMarge(tauxPct: number | null | undefined): NiveauMarge | null { + if (tauxPct == null || !Number.isFinite(tauxPct)) return null; + if (tauxPct >= SEUILS_MARGE.bon) return "bon"; + if (tauxPct >= SEUILS_MARGE.moyen) return "moyen"; + return "faible"; +} + +/** Couleur CSS (variable du thème) associée au niveau de marge. */ +export function couleurMarge(tauxPct: number | null | undefined): string { + const niveau = niveauMarge(tauxPct); + if (niveau === "bon") return "var(--accent-success)"; + if (niveau === "moyen") return "var(--accent-warning)"; + if (niveau === "faible") return "var(--accent-error)"; + return "var(--text-muted)"; +} diff --git a/src/middleware.ts b/src/middleware.ts index f7c6ca7..6f4a357 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -26,9 +26,9 @@ export default auth((req) => { return NextResponse.redirect(new URL("/login", nextUrl)); } - // 3. Rediriger vers le dashboard si déjà connecté et sur /login + // 3. Déjà connecté et sur /login : page d'accueil (la même que « / »). if (isLoggedIn && isPublicRoute) { - return NextResponse.redirect(new URL("/grid", nextUrl)); + return NextResponse.redirect(new URL("/dashboard", nextUrl)); } // 4. Protection par rôle (Admin seulement pour les paramètres) @@ -36,7 +36,7 @@ export default auth((req) => { const userRole = (req.auth?.user as any)?.role; if (isAdminRoute && userRole !== "admin") { - return NextResponse.redirect(new URL("/grid", nextUrl)); + return NextResponse.redirect(new URL("/dashboard", nextUrl)); } return NextResponse.next(); From 220ca2263a0bc88f8b4533953cd1521bc1ad0ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:05:46 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(ui):=20lot=203=20=E2=80=94=20toutes=20?= =?UTF-8?q?les=20pages=20lisibles,=20coh=C3=A9rentes=20et=20en=20fran?= =?UTF-8?q?=C3=A7ais=20clair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn --- docs/plan-optimisation-webui.md | 29 + src/app/(auth)/login/page.tsx | 15 +- .../admin/synchronisation/client.tsx | 713 +++++++---- .../admin/synchronisation/page.tsx | 5 +- src/app/(dashboard)/analytics/client.tsx | 426 +++---- src/app/(dashboard)/analytics/page.tsx | 158 +-- .../commandes-auto/cadencier-client.tsx | 598 ++++++---- src/app/(dashboard)/commandes-auto/client.tsx | 369 +++--- src/app/(dashboard)/commandes-auto/page.tsx | 24 +- src/app/(dashboard)/commandes-auto/tabs.tsx | 51 +- src/app/(dashboard)/dashboard/page.tsx | 1056 ++++------------- src/app/(dashboard)/grid/page.tsx | 11 +- src/app/(dashboard)/hit-parade/client.tsx | 578 ++++----- src/app/(dashboard)/hit-parade/page.tsx | 43 +- src/app/(dashboard)/produits/fiche.tsx | 860 ++++++++------ src/app/(dashboard)/produits/opportunites.tsx | 191 +-- src/app/(dashboard)/produits/page.tsx | 71 +- src/app/(dashboard)/produits/results.tsx | 485 ++++---- src/app/(dashboard)/produits/search-bar.tsx | 41 +- src/app/(dashboard)/publicites/client.tsx | 705 ++++++----- src/app/(dashboard)/publicites/page.tsx | 43 +- src/app/(dashboard)/settings/client.tsx | 619 ++++++++++ src/app/(dashboard)/settings/page.tsx | 550 +-------- src/app/(dashboard)/stock-negatif/client.tsx | 788 +++++------- src/app/(dashboard)/stock-negatif/page.tsx | 70 +- .../shared/change-password-modal.tsx | 203 ---- src/components/shared/confirm-modal.tsx | 127 -- src/components/shared/loading-modal.tsx | 45 - src/components/shared/sidebar.tsx | 4 +- src/components/shared/success-modal.tsx | 130 -- src/components/ui/tooltip.tsx | 12 + .../admin/components/api-connection-info.tsx | 343 +++--- .../admin/components/api-key-management.tsx | 402 ++++--- src/features/admin/components/grid-warmup.tsx | 156 +-- .../admin/components/user-management.tsx | 497 +++++--- src/features/auth/components/login-form.tsx | 85 +- .../grid/components/assortment-gaps-modal.tsx | 12 +- .../grid/components/bulk-action-toolbar.tsx | 46 +- .../grid/components/export-dropdown.tsx | 244 ---- .../grid/components/financial-cell.tsx | 52 - .../grid/components/floating-summary-bar.tsx | 449 +++---- src/features/grid/components/gamme-select.tsx | 54 +- src/features/grid/components/grid-client.tsx | 226 +--- .../grid/components/grid-filter-bar.tsx | 68 +- src/features/grid/components/heatmap-grid.tsx | 216 ++-- .../grid/components/network-charts.tsx | 6 +- .../grid/components/nomenclature-filter.tsx | 2 +- .../grid/components/product-monthly-modal.tsx | 4 +- src/features/grid/components/stat-tile.tsx | 2 +- .../grid/components/store-combobox.tsx | 115 +- .../grid/components/supplier-combobox.tsx | 74 +- .../components/supplier-selection-landing.tsx | 46 +- .../grid/components/supplier-selection.tsx | 73 -- .../grid/components/sync-qlik-button.tsx | 12 +- src/features/grid/hooks/use-save-drafts.ts | 10 +- src/features/grid/lib/exports.ts | 180 +++ src/features/grid/lib/months.ts | 11 +- .../settings/components/server-logs.tsx | 125 +- .../snapshots/components/snapshot-list.tsx | 430 ++++--- src/lib/auth.ts | 3 + src/lib/glossaire.ts | 2 +- 61 files changed, 6101 insertions(+), 6864 deletions(-) create mode 100644 src/app/(dashboard)/settings/client.tsx delete mode 100644 src/components/shared/change-password-modal.tsx delete mode 100644 src/components/shared/confirm-modal.tsx delete mode 100644 src/components/shared/loading-modal.tsx delete mode 100644 src/components/shared/success-modal.tsx delete mode 100644 src/features/grid/components/export-dropdown.tsx delete mode 100644 src/features/grid/components/financial-cell.tsx delete mode 100644 src/features/grid/components/supplier-selection.tsx create mode 100644 src/features/grid/lib/exports.ts diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md index f68e243..e2a86af 100644 --- a/docs/plan-optimisation-webui.md +++ b/docs/plan-optimisation-webui.md @@ -304,3 +304,32 @@ Règle commune à chaque page : - pages d'erreur (`error.tsx`) et « page introuvable » en français ; - après connexion, arrivée sur l'Accueil, comme pour `/`. - **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF. + +## Suivi — Lot 3 réalisé + +Toutes les pages utilisent désormais : +- `PageHeader` avec une phrase d'explication ; +- les couleurs du thème (plus de pages figées en blanc) ; +- des textes d'au moins 12 px ; +- les noms de magasins, `toast` et `confirmer`. + +Vérifié dans un navigateur : sur les pages visitées, en clair et en sombre, aucun texte n'est sous 12 px. + +| Page | Changements principaux | +|---|---| +| Accueil | Cartes « Nos 2 magasins » : CA, tickets et panier moyen, avec l'évolution N-1. Une carte par magasin. Top 10 lisible, avec un lien vers la fiche produit. | +| Révision d'assortiment | **Barre du bas** : seul endroit pour enregistrer et exporter, avec les mêmes droits pour tous. Le menu Export regroupe les deux anciens. Bouton « Annuler » avec confirmation. **Filtres** : sens des gammes affiché (A Cœur…), magasin choisi en un clic, menu « Colonnes » en clair. **Chargement** : bandeau de progression au lieu de la fenêtre floue. **Droits** : lus côté serveur. | +| Recherche produit | Fiche lisible (Secteur / Rayon / Famille), infobulles PCB, PRMP, code-barres, réseau. Plus de mention technique de Qlik. | +| Stocks à surveiller | `DataTable` avec trois onglets et une explication pour chacun. **Export Excel inchangé** (format d'import FF). | +| Meilleures ventes | `DataTable` avec un tri sur toutes les colonnes et une ligne de totaux. Export via exceljs (`xlsx` n'est plus utilisé). | +| Ventes par mois | Dernier mois complet par défaut, mois en clair, bascule Fournisseur / Famille, recherche, tri, export. | +| Publicités | Onglets, statuts lisibles, part du CA calculée en pied de tableau. Le tri ne porte que sur la page affichée, et la page le signale. | +| Commandes fournisseurs | Onglets dans l'URL, confirmation avant suppression, retours de toutes les actions. | +| Historique | « Reprendre dans la Grille », chiffres visibles, états vides et d'erreur. | +| Paramètres | Onglets Connexions / Utilisateurs / Accès API / Journal serveur, un bouton Enregistrer par section. | +| Synchronisation | Vocabulaire clair, unités affichées, résumé en indicateurs. | +| Connexion | Libellés lisibles, fond sobre. | + +Autres changements : +- **Composants retirés** : `SuccessModal`, `ConfirmModal`, `ChangePasswordModal`, `LoadingModal`, `export-dropdown`, `financial-cell` et `supplier-selection`, qui n'étaient plus utilisés. +- **Correction** : le nom de l'utilisateur est maintenant transmis à la session, et l'en-tête l'affiche. diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5eb7011..f31bd72 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,17 +1,12 @@ +import type { Metadata } from "next"; import { LoginForm } from "@/features/auth/components/login-form"; +export const metadata: Metadata = { title: "Connexion" }; + export default function LoginPage() { return ( -
- {/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */} -
-
-
-
- -
- -
+
+
); } diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 929e1e1..0f6c649 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -3,9 +3,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, - CheckCircle, Database, Network, Search, + CheckCircle2, Database, Network, Clock, } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { PageHeader } from "@/components/ui/page-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge, type Ton } from "@/components/ui/badge"; +import { Input, Label, SearchInput } from "@/components/ui/form-controls"; +import { Segmented, type TabItem } from "@/components/ui/tabs"; +import { DataTable, type DataColumn } from "@/components/ui/data-table"; +import { StatCard } from "@/components/ui/stat-card"; +import { ErrorState, Skeleton } from "@/components/ui/states"; +import { Tooltip } from "@/components/ui/tooltip"; +import { confirmer, toast } from "@/components/ui/feedback"; +import { fmtEntier } from "@/lib/format"; interface SyncSettings { actif: boolean; @@ -51,18 +62,46 @@ interface Resume { 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`; +type Filtre = "tous" | "actifs" | "inactifs" | "echecs"; -function Pastille({ statut }: { statut: string | null }) { - if (!statut) return —; - const couleurs: Record = { - succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500", - }; - const libelles: Record = { succes: "ok", echec: "échec", vide: "vide" }; - return {libelles[statut] ?? statut}; +/** Libellés des deux sources, identiques partout dans la page. */ +const VENTES = "Ventes (base FF)"; +const RESEAU = "Réseau (Qlik)"; + +const fmtDate = (iso: string | null) => + iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "Jamais"; + +function fmtDuree(ms: number | null): string { + if (ms == null) return ""; + if (ms < 1000) return `${ms} ms`; + const s = Math.round(ms / 1000); + return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`; +} + +const STATUTS: Record = { + succes: { ton: "succes", libelle: "OK", aide: "Dernière mise à jour réussie" }, + echec: { ton: "erreur", libelle: "Échec", aide: "La dernière mise à jour a échoué" }, + vide: { ton: "alerte", libelle: "Vide", aide: "Aucune donnée reçue lors de la dernière mise à jour" }, +}; + +function StatutBadge({ statut }: { statut: string | null }) { + if (!statut) return null; + const s = STATUTS[statut]; + return s ? {s.libelle} : {statut}; +} + +const CASE = "h-4 w-4 cursor-pointer accent-[var(--accent)]"; + +function correspond(r: FournisseurRow, f: Filtre): boolean { + if (f === "actifs") return r.actifSql || r.actifQlik; + if (f === "inactifs") return !r.actifSql && !r.actifQlik; + if (f === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec"; + return true; +} + +/** « Lancement automatique » (planifié la nuit) ou « manuel » (bouton). */ +function libelleOrigine(origine: SyncState["origine"]): string { + return origine === "auto" ? " automatique" : origine === "manuel" ? " manuel" : ""; } export function SynchronisationClient() { @@ -74,7 +113,8 @@ export function SynchronisationClient() { const [erreur, setErreur] = useState(null); const [chargement, setChargement] = useState(true); const [recherche, setRecherche] = useState(""); - const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous"); + const [filtre, setFiltre] = useState("tous"); + const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null); // ── Lectures pures : aucun setState, pour rester appelables depuis un effet ── const lireEtat = useCallback(async () => { @@ -107,7 +147,12 @@ export function SynchronisationClient() { useEffect(() => { rafraichirTout(); }, [rafraichirTout]); - // Suivi rapproché pendant un round : l'état change à chaque fournisseur. + const reessayer = () => { + setChargement(true); + rafraichirTout(); + }; + + // Suivi rapproché pendant un lancement : l'état change à chaque fournisseur. useEffect(() => { if (!state?.enCours) return; const id = setInterval(rafraichirEtat, 3000); @@ -119,7 +164,6 @@ export function SynchronisationClient() { const minuteurPatch = useRef | null>(null); const majSettings = async (patch: Partial) => { - setErreur(null); try { const res = await fetch("/api/admin/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), @@ -128,7 +172,9 @@ export function SynchronisationClient() { if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); // Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse. setSettings({ ...d.settings, ...patchEnAttente.current }); - } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } + } catch (e) { + toast.erreur(`Le réglage n'a pas pu être enregistré : ${e instanceof Error ? e.message : String(e)}`); + } }; // Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur @@ -147,19 +193,41 @@ export function SynchronisationClient() { }; const action = async (act: "start" | "stop" | "seed", forcer = false) => { - setErreur(null); + setActionEnCours(act); 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}`); + if (act === "seed") { + toast.succes(`Liste des fournisseurs actualisée (${fmtEntier(Number(d.fournisseurs) || 0)} dans le référentiel FF).`); + } else if (act === "stop") { + toast.info("Arrêt demandé : la mise à jour s'arrête après le fournisseur en cours."); + } rafraichirTout(); - } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } + } catch (e) { + const libelles = { start: "Le lancement a échoué", stop: "L'arrêt a échoué", seed: "La liste n'a pas pu être actualisée" }; + toast.erreur(`${libelles[act]} : ${e instanceof Error ? e.message : String(e)}`); + } finally { + setActionEnCours(null); + } + }; + + const lancer = async () => { + if (!dansLaFenetre) { + const ok = await confirmer({ + titre: "Lancer la mise à jour maintenant ?", + message: "Nous sommes en dehors de la plage horaire nocturne : la mise à jour va solliciter la base FF et Qlik en pleine journée, ce qui peut ralentir l'application pour les magasins.", + libelleConfirmer: "Lancer maintenant", + }); + if (!ok) return; + } + void action("start", !dansLaFenetre); }; const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => { - setErreur(null); + if (codes.length === 0) return; // 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) @@ -172,7 +240,7 @@ export function SynchronisationClient() { }); if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); } catch (e) { - setErreur(e instanceof Error ? e.message : String(e)); + toast.erreur(`La modification n'a pas pu être enregistrée : ${e instanceof Error ? e.message : String(e)}`); rafraichirTout(); // l'optimisme était infondé : on resynchronise } }; @@ -181,254 +249,385 @@ export function SynchronisationClient() { 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; + return correspond(r, filtre); }); }, [rows, recherche, filtre]); const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]); - return ( -
-
-

- - Synchronisation nocturne -

-

- 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'est arrêté ; quand tout le - monde est passé, un nouveau tour recommence. -

+ const basculerEnMasse = async (champ: "actifSql" | "actifQlik", valeur: boolean) => { + const n = codesVisibles.length; + if (n === 0) return; + if (!valeur) { + const quoi = champ === "actifSql" ? "des ventes (base FF)" : "des données du réseau (Qlik)"; + const ok = await confirmer({ + titre: `Désactiver la mise à jour ${quoi} ?`, + message: `${fmtEntier(n)} fournisseur${n > 1 ? "s" : ""} de la liste affichée ne ser${n > 1 ? "ont" : "a"} plus mis à jour la nuit pour cette source. Vous pourrez les réactiver à tout moment.`, + libelleConfirmer: "Désactiver", + danger: true, + }); + if (!ok) return; + } + void basculer(codesVisibles, champ, valeur); + }; - {erreur && ( -
- - {erreur} -
- )} + const filtres: readonly TabItem[] = useMemo(() => [ + { value: "tous", label: "Tous", count: rows.length }, + { value: "actifs", label: "Actifs", count: rows.filter((r) => correspond(r, "actifs")).length }, + { value: "inactifs", label: "Inactifs", count: rows.filter((r) => correspond(r, "inactifs")).length }, + { value: "echecs", label: "En échec", count: rows.filter((r) => correspond(r, "echecs")).length }, + ], [rows]); - {/* ── Réglages ── */} - {settings && ( -
-
- - -
-
- - majSettingsDifferee({ 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)" }} /> -
- h à -
- - majSettingsDifferee({ 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)" }} /> -
- h -
- -
- - majSettingsDifferee({ 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)" }} /> -
-
- - majSettingsDifferee({ 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)" }} /> -
- -
- - {state?.enCours ? ( - - ) : ( - - )} -
- -

- {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'elle - monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin. -

-
- )} - - {/* ── Round en cours ── */} - {state && (state.enCours || state.finAt) && ( -
-
- {state.enCours - ? - : } - - {state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"} - - {state.enCours && state.fournisseurCourant && ( - - — {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant} - - )} -
-
- SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`} - Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`} - {state.debutAt && Début {fmtDate(state.debutAt)}} - {state.tourTermine && Tour bouclé — tout le monde est à jour} -
- {state.derniereErreur && ( -

Dernière erreur : {state.derniereErreur}

- )} -
- )} - - {/* ── Résumé ── */} - {resume && ( -
- {[ - { 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) => ( - - {s.l} {s.v} - - ))} -
- )} - - {/* ── Filtres et actions de masse ── */} -
-
- - 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)" }} /> -
- {(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => ( - - ))} -
- {visibles.length} affichés : - - - - + const colonnes: DataColumn[] = [ + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur, + cell: (r) => ( +
+
{r.nomFournisseur ?? r.codeFournisseur}
+
{r.codeFournisseur}
- - {/* ── Tableau ── */} -
- {chargement ? ( -
- Chargement… -
- ) : visibles.length === 0 ? ( -
- Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel. -
- ) : ( -
- - - - {["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => ( - - ))} - - - - {visibles.map((r) => ( - - - - - - - - - ))} - -
{h}
-
- {r.nomFournisseur ?? r.codeFournisseur} -
-
{r.codeFournisseur}
-
- basculer([r.codeFournisseur], "actifSql", e.target.checked)} /> - - {" "} - {fmtDate(r.dernierSqlAt)} - {r.dernierSqlLignes != null && · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}} - - basculer([r.codeFournisseur], "actifQlik", e.target.checked)} /> - - {" "} - {fmtDate(r.dernierQlikAt)} - {r.dernierQlikCodes != null && · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes} - - {r.desactiveMotif} -
+ ), + }, + { + id: "actifSql", + header: VENTES, + hint: "Cochez pour mettre à jour chaque nuit les ventes de ce fournisseur dans nos magasins.", + align: "center", + sortValue: (r) => (r.actifSql ? 1 : 0), + cell: (r) => ( + basculer([r.codeFournisseur], "actifSql", e.target.checked)} + /> + ), + }, + { + id: "dernierSql", + header: "Dernière mise à jour des ventes", + sortValue: (r) => r.dernierSqlAt, + cell: (r) => ( +
+
+ + {fmtDate(r.dernierSqlAt)} +
+ {r.dernierSqlLignes != null && ( +
+ {fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}}
)}
+ ), + }, + { + id: "actifQlik", + header: RESEAU, + hint: "Cochez pour mettre à jour les ventes de ce fournisseur dans les magasins du réseau. Ces extractions sont longues : leur nombre par nuit est plafonné.", + align: "center", + sortValue: (r) => (r.actifQlik ? 1 : 0), + cell: (r) => ( + basculer([r.codeFournisseur], "actifQlik", e.target.checked)} + /> + ), + }, + { + id: "dernierQlik", + header: "Dernière mise à jour réseau", + sortValue: (r) => r.dernierQlikAt, + cell: (r) => ( +
+
+ + {fmtDate(r.dernierQlikAt)} +
+ {r.dernierQlikCodes != null && ( +
{fmtEntier(r.dernierQlikCodes)} produits
+ )} +
+ ), + }, + { + id: "remarque", + header: "Remarque", + cell: (r) => (r.desactiveMotif + ? {r.desactiveMotif} + : null), + }, + ]; + + if (chargement) { + return ( +
+ +
+ + + +
+ ); + } + + if (!settings && erreur) { + return ( +
+ + Réessayer} + /> +
+ ); + } + + return ( +
+ action("stop")} disabled={actionEnCours === "stop"} + className="border-[var(--accent-error)] text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"> + {actionEnCours === "stop" ? : } + Arrêter + + ) : ( + + )} + /> + + {erreur && ( +
+ + + Les informations n'ont pas pu être actualisées : {erreur} + + +
+ )} + + {/* ── Réglages ── */} + {settings && ( + + + + + +
+
+ +
+ de + majSettingsDifferee({ heureDebut: Number(e.target.value) })} + className="w-16 text-center tabular-nums" /> + h à + majSettingsDifferee({ heureFin: Number(e.target.value) })} + className="w-16 text-center tabular-nums" /> + h +
+

+ Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout. +

+
+ +
+ + majSettingsDifferee({ qlikParNuit: Number(e.target.value) })} + className="w-20 text-center tabular-nums" /> +

+ Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit. +

+
+ +
+ +
+ Actualiser les données réseau de plus de + majSettingsDifferee({ qlikMinJours: Number(e.target.value) })} + className="w-20 text-center tabular-nums" /> + jours +
+

+ Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien. +

+
+
+

Les modifications sont enregistrées automatiquement.

+
+
+ )} + + {/* ── Lancement en cours ou dernier lancement ── */} + {state && ( + + +
+ {state.enCours + ? + : state.finAt + ? + : } + + {state.enCours + ? `Lancement${libelleOrigine(state.origine)} en cours` + : state.finAt + ? `Dernier lancement${libelleOrigine(state.origine)} terminé` + : "Aucun lancement depuis le démarrage du serveur"} + + {state.enCours && state.fournisseurCourant && ( + + : {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant} + + )} + {state.tourTermine && ( + Tous les fournisseurs ont été traités + )} +
+ + {(state.enCours || state.finAt) && ( +
+ + + {VENTES} : {fmtEntier(state.sqlFaits)} à jour + {state.sqlEchecs > 0 && , {fmtEntier(state.sqlEchecs)} en échec} + + + + {RESEAU} : {fmtEntier(state.qlikFaits)} à jour + {state.qlikEchecs > 0 && , {fmtEntier(state.qlikEchecs)} en échec} + + {state.debutAt && Commencé le {fmtDate(state.debutAt)}} + {!state.enCours && state.finAt && Terminé le {fmtDate(state.finAt)}} +
+ )} + + {state.derniereErreur && ( +

Dernière erreur : {state.derniereErreur}

+ )} + +

+ {dansLaFenetre + ? "Nous sommes actuellement dans la plage horaire nocturne." + : "Hors de la plage horaire : « Lancer maintenant » démarre quand même une mise à jour immédiate."} +

+
+
+ )} + + {/* ── Résumé ── */} + {resume && ( +
+ + + + + + Retirés automatiquement + + } + value={fmtEntier(resume.desactivesAuto)} + hint="aucun article dans la base FF" + /> + +
+ )} + + {/* ── Fournisseurs ── */} + + action("seed")} disabled={actionEnCours === "seed"} + title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms"> + {actionEnCours === "seed" ? : } + Actualiser la liste des fournisseurs + + } + /> + +
+ + Pour les {fmtEntier(visibles.length)} fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous : + + + + +
+ + r.codeFournisseur} + pageSize={0} + unite="fournisseurs" + toolbar={ + <> + + + + } + emptyTitle={rows.length === 0 ? "Aucun fournisseur" : "Aucun fournisseur ne correspond"} + emptyDescription={rows.length === 0 + ? "Utilisez « Actualiser la liste des fournisseurs » pour importer le référentiel FF." + : "Modifiez la recherche ou choisissez « Tous »."} + /> +
+
); } + +const DESCRIPTION = + "Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik)."; diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx index 7956321..ce8c239 100644 --- a/src/app/(dashboard)/admin/synchronisation/page.tsx +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -1,8 +1,7 @@ +import type { Metadata } from "next"; import { SynchronisationClient } from "./client"; -export const metadata = { - title: "Synchronisation nocturne — CollectFlow", -}; +export const metadata: Metadata = { title: "Synchronisation" }; /** * Paramétrage de la synchronisation nocturne des fournisseurs. diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx index 06f507b..4291bf9 100644 --- a/src/app/(dashboard)/analytics/client.tsx +++ b/src/app/(dashboard)/analytics/client.tsx @@ -1,55 +1,95 @@ "use client"; import { useRouter } from "next/navigation"; +import { useMemo, useTransition, type ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { DataTable, type DataColumn } from "@/components/ui/data-table"; +import { Segmented, type TabItem } from "@/components/ui/tabs"; +import { Select } from "@/components/ui/form-controls"; +import { DeltaBadge } from "@/components/ui/badge"; +import { Terme } from "@/components/ui/tooltip"; +import { MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtEur0 } from "@/lib/format"; +import { telechargerExcel } from "@/lib/export-excel"; +import { cn } from "@/lib/utils"; + +export type ModeAnalytics = "fournisseur" | "nomenclature"; + +export interface OptionMois { + value: string; + label: string; +} + +export interface AnalyticsRow { + key: string; + label: string; + ca292: number; + caN1_292: number; + ca579: number; + caN1_579: number; + caTotal: number; + caN1Total: number; + evolutionTotal: number | null; +} interface AnalyticsClientProps { - mode: string; + mode: ModeAnalytics; + /** Mois affiché, « AAAA-MM ». */ mois: string; - moisN1: string; - currentMois: string; - pivotted: Array<{ - key: string; - label: string; - ca292: number; - caN1_292: number; - ca579: number; - caN1_579: number; - caTotal: number; - caN1Total: number; - evolutionTotal: number | null; - }>; - totals: { - ca292: number; - caN1_292: number; - ca579: number; - caN1_579: number; - caTotal: number; - caN1Total: number; - }; - evolutionTotal292: number | null; - evolutionTotal579: number | null; - evolutionTotalReseau: number | null; + /** Mois en clair : « octobre 2026 ». */ + libelleMois: string; + libelleMoisN1: string; + moisDisponibles: OptionMois[]; + pivotted: AnalyticsRow[]; } -function formatCA(value: number): string { - return new Intl.NumberFormat("fr-FR", { - style: "currency", - currency: "EUR", - maximumFractionDigits: 0, - }).format(value); +/** Champs numériques de la ligne pivotée. */ +type Champ = { [K in keyof AnalyticsRow]: AnalyticsRow[K] extends number ? K : never }[keyof AnalyticsRow]; + +interface Groupe { + id: string; + titre: string; + ca: Champ; + caN1: Champ; } -function EvolutionBadge({ value }: { value: number | null }) { - if (value === null) return N/A; +/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */ +const CHAMPS_MAGASIN: Record> = { + "292": { ca: "ca292", caN1: "caN1_292" }, + "579": { ca: "ca579", caN1: "caN1_579" }, +}; - const isPositive = value >= 0; - const bgColor = isPositive ? "bg-green-100" : "bg-red-100"; - const textColor = isPositive ? "text-green-700" : "text-red-700"; +const GROUPES: Groupe[] = [ + ...MAGASINS.filter((m) => CHAMPS_MAGASIN[m.code]).map((m) => ({ + id: m.code, + titre: nomMagasin(m.code), + ...CHAMPS_MAGASIN[m.code], + })), + { id: "total", titre: "Total nos magasins", ca: "caTotal", caN1: "caN1Total" }, +]; +const MODES: TabItem[] = [ + { value: "fournisseur", label: "Fournisseur" }, + { value: "nomenclature", label: "Famille" }, +]; + +const somme = (rows: readonly AnalyticsRow[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0); + +/** Évolution en % par rapport à N-1 ; `null` sans chiffre d'affaires N-1. */ +const evolution = (ca: number, caN1: number) => (caN1 > 0 ? ((ca - caN1) / caN1) * 100 : null); + +const recherche = (r: AnalyticsRow) => [r.label, r.key]; + +function Montant({ v }: { v: number }) { + return {fmtEur0(v)}; +} + +/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ +function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { return ( - - {isPositive ? "+" : ""} - {value.toFixed(1)}% + + {groupe} + {children} ); } @@ -57,193 +97,159 @@ function EvolutionBadge({ value }: { value: number | null }) { export function AnalyticsClient({ mode, mois, - moisN1, - currentMois, + libelleMois, + libelleMoisN1, + moisDisponibles, pivotted, - totals, - evolutionTotal292, - evolutionTotal579, - evolutionTotalReseau, }: AnalyticsClientProps) { const router = useRouter(); + const [navigation, demarrerNavigation] = useTransition(); const handleModeChange = (newMode: string) => { - router.push(`/analytics?mode=${newMode}&mois=${mois}`); + demarrerNavigation(() => { + router.push(`/analytics?mode=${newMode}&mois=${mois}`); + }); }; const handleMonthChange = (newMois: string) => { - router.push(`/analytics?mode=${mode}&mois=${newMois}`); + demarrerNavigation(() => { + router.push(`/analytics?mode=${mode}&mois=${newMois}`); + }); }; - // Générer liste des 24 derniers mois - const months: { value: string; label: string }[] = []; - const now = new Date(); - for (let i = 0; i < 24; i++) { - const d = new Date(now.getFullYear(), now.getMonth() - i); - const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; - const label = new Intl.DateTimeFormat("fr-FR", { year: "numeric", month: "long" }).format(d); - months.push({ value, label: label.charAt(0).toUpperCase() + label.slice(1) }); + const libelleColonne = mode === "fournisseur" ? "Fournisseur" : "Famille"; + + const colonnes = useMemo[]>(() => [ + { + id: "label", + header: libelleColonne, + sortValue: (r) => r.label, + cell: (r) => {r.label}, + footer: () => "Total", + className: "min-w-[220px]", + }, + ...GROUPES.flatMap((g): DataColumn[] => { + const total = g.id === "total"; + const bord = "border-l border-[var(--border)]"; + return [ + { + id: `${g.id}-ca`, + header: {libelleMois}, + hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`, + align: "right", + sortValue: (r) => r[g.ca], + cell: (r) => , + footer: (rows) => , + className: cn(bord, total && "font-semibold"), + headerClassName: bord, + }, + { + id: `${g.id}-n1`, + header: ( + + {libelleMoisN1} · + + ), + hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`, + align: "right", + sortValue: (r) => r[g.caN1], + cell: (r) => , + footer: (rows) => , + className: cn(total && "font-semibold"), + }, + { + id: `${g.id}-evolution`, + header: Évolution, + hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`, + align: "right", + sortValue: (r) => evolution(r[g.ca], r[g.caN1]), + cell: (r) => , + footer: (rows) => , + }, + ]; + }), + ], [libelleColonne, libelleMois, libelleMoisN1]); + + async function exporter(rows: readonly AnalyticsRow[]) { + const arrondi = (v: number) => Math.round(v * 100) / 100; + const evol = (ca: number, caN1: number) => { + const e = evolution(ca, caN1); + return e === null ? null : Math.round(e * 10) / 10; + }; + await telechargerExcel({ + feuille: "Ventes par mois", + fichier: `ventes-par-mois_${mode === "fournisseur" ? "fournisseurs" : "familles"}_${mois}`, + entetes: [ + libelleColonne, + ...GROUPES.flatMap((g) => [ + `${g.titre} — CA ${libelleMois}`, + `${g.titre} — CA ${libelleMoisN1} (N-1)`, + `${g.titre} — Évolution (%)`, + ]), + ], + largeurs: [40, ...GROUPES.flatMap(() => [22, 22, 16])], + lignes: rows.map((r) => [ + r.label, + ...GROUPES.flatMap((g) => [arrondi(r[g.ca]), arrondi(r[g.caN1]), evol(r[g.ca], r[g.caN1])]), + ]), + totaux: [ + "Total", + ...GROUPES.flatMap((g) => { + const ca = somme(rows, g.ca); + const caN1 = somme(rows, g.caN1); + return [arrondi(ca), arrondi(caN1), evol(ca, caN1)]; + }), + ], + }); } return ( -
- {/* Controls */} -
-
- - -
- -
- - -
- -
- Comparaison N-1 : {moisN1} +
+ {/* Contrôles (relancent le calcul côté serveur) */} +
+
+ + Afficher par + +
+ setValue(e.target.value)} - placeholder="Rechercher…" - className="min-w-[260px] 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" - /> - - {fournisseurs.map((f) => ( - +
+

Ajouter un rappel pour ce magasin

+
+
+ + setSaisie(e.target.value)} + placeholder="Tapez le nom ou le code…" + autoComplete="off" + aria-invalid={erreur !== null || undefined} + aria-describedby={erreur ? idErreur : undefined} + className="w-[300px] max-w-full" + /> + + {fournisseurs.map((f) => ( + +
+
+ + setSemaines(e.target.value)} + className="w-32" + /> +
+
-
- - setSemaines(parseInt(e.target.value, 10))} - className="w-28 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" - /> -
- - {err && {err}} -
+ {erreur && ( + + )} + ); } -function SiteSection({ +const TH = "whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-left text-[13px] font-semibold text-[var(--text-secondary)]"; + +function SectionMagasin({ site, - label, rows, fournisseurs, }: { site: string; - label: string; rows: CadenceView[]; - fournisseurs: { code: string; nom: string }[]; + fournisseurs: Fournisseur[]; }) { const [pending, startTransition] = useTransition(); + const nom = nomMagasin(site); - const sorted = useMemo(() => trier(rows), [rows]); - const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]); + const triees = useMemo(() => trier(rows), [rows]); + const codesExistants = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]); const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length; - // Pas de router.refresh() : les actions appellent déjà revalidatePath, dont - // la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois. - function run(fn: () => Promise) { - startTransition(async () => { - await fn(); + /** + * Exécute une action serveur et en rend compte : message de réussite, ou + * message d'erreur (refus de l'action ou exception) — jamais d'échec muet. + * Pas de router.refresh() : les actions appellent déjà revalidatePath, dont + * la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois. + */ + function executer(action: () => Promise, messages: { succes: string; echec: string }): Promise { + return new Promise((resolve) => { + startTransition(async () => { + try { + const resultat = await action(); + if (resultat.ok) toast.succes(messages.succes); + else toast.erreur(resultat.error ?? messages.echec); + resolve(resultat.ok); + } catch (e) { + console.error("[cadencier]", e); + toast.erreur(`${messages.echec} Vérifiez votre connexion puis réessayez.`); + resolve(false); + } + }); }); } - const add = (input: { codefou: string; nomfou: string; semaines: number }) => - run(() => - upsertCadence({ - codefou: input.codefou, - nomfou: input.nomfou, - site, - intervalleSemaines: input.semaines, - actif: true, - }), + const ajouter = (input: { codefou: string; nomfou: string; semaines: number }) => + executer( + () => + upsertCadence({ + codefou: input.codefou, + nomfou: input.nomfou, + site, + intervalleSemaines: input.semaines, + actif: true, + }), + { + succes: `Rappel ajouté : ${input.nomfou}, toutes les ${pluriel(input.semaines, "semaine")}.`, + echec: "Le rappel n'a pas pu être ajouté.", + }, ); - const changeSemaines = (r: CadenceView, semaines: number) => - run(() => - upsertCadence({ - codefou: r.codefou, - nomfou: r.nomfou, - site, - intervalleSemaines: semaines, - actif: r.actif, - }), + const changerSemaines = (r: CadenceView, semaines: number) => + executer( + () => + upsertCadence({ + codefou: r.codefou, + nomfou: r.nomfou, + site, + intervalleSemaines: semaines, + actif: r.actif, + }), + { + succes: `Rythme enregistré : ${r.nomfou}, toutes les ${pluriel(semaines, "semaine")}.`, + echec: "Le rythme n'a pas pu être enregistré.", + }, ); + const basculer = (r: CadenceView) => + executer(() => toggleCadence(r.codefou, r.site, !r.actif), { + succes: r.actif ? `Rappel désactivé : ${r.nomfou}.` : `Rappel réactivé : ${r.nomfou}.`, + echec: r.actif ? "Le rappel n'a pas pu être désactivé." : "Le rappel n'a pas pu être réactivé.", + }); + + async function supprimer(r: CadenceView) { + const ok = await confirmer({ + titre: "Supprimer ce rappel ?", + message: `Le rythme de commande de ${r.nomfou} pour ${nom} sera supprimé. Pour simplement suspendre les alertes, utilisez plutôt « Désactiver ».`, + libelleConfirmer: "Supprimer", + danger: true, + }); + if (!ok) return; + await executer(() => removeCadence(r.codefou, r.site), { + succes: `Rappel supprimé : ${r.nomfou}.`, + echec: "Le rappel n'a pas pu être supprimé.", + }); + } + return ( -
-
-

{label}

- - {rows.length} cadencé{rows.length !== 1 ? "s" : ""} - +
+
+

{nom}

+ + {pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""} + {nbACommander > 0 && ( - - {nbACommander} à commander - + + {nbACommander} à commander + )}
- -
- - - - - - - - - - - - - - - {sorted.length === 0 && ( + {triees.length === 0 ? ( + + ) : ( +
+
CodeFournisseurToutes lesDernière réceptionProchaine commandeTemps restantStatutActions
+ - + + + + + + + - )} - {sorted.map((r) => { - const urgent = r.actif && r.statut === "a_commander"; - return ( - - - - - - - - - - - ); - })} - -
- Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus. - FournisseurRythmeDernière réception + + Prochaine commande + + Temps restantStatutActions
{r.codefou}{r.nomfou} - - { - const v = parseInt(e.target.value, 10); - if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v); - }} - className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - sem. - - {fmtDate(r.derniereReception)}{fmtDate(r.echeance)} - {tempsRestant(r.joursRestants)} - - - -
- - -
-
-
+ + + {triees.map((r) => { + const urgent = r.actif && r.statut === "a_commander"; + const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]"; + return ( + + +
{r.nomfou}
+
Code {r.codefou}
+ + + + Toutes les + { + if (e.key === "Enter") e.currentTarget.blur(); + }} + onBlur={(e) => { + const champ = e.currentTarget; + const brut = champ.value.trim(); + const v = Number(brut); + if (brut !== "" && v === r.intervalleSemaines) return; + if (brut === "" || !Number.isInteger(v) || v < SEMAINES_MIN || v > SEMAINES_MAX) { + champ.value = String(r.intervalleSemaines); + toast.erreur( + `Rythme non enregistré : indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`, + ); + return; + } + void changerSemaines(r, v).then((ok) => { + if (!ok) champ.value = String(r.intervalleSemaines); + }); + }} + className="h-8 w-20 px-2 text-center" + /> + semaines + + + + {fmtDate(r.derniereReception)} + + + {fmtDate(r.echeance)} + + + {tempsRestant(r.joursRestants)} + + + + + +
+ + + + + + +
+ + + ); + })} + + +
+ )}
); } @@ -315,9 +459,9 @@ export function CadencierClient({ fournisseurs, }: { cadences: CadenceView[]; - fournisseurs: { code: string; nom: string }[]; + fournisseurs: Fournisseur[]; }) { - const bySite = useMemo(() => { + const parMagasin = useMemo(() => { const m = new Map(); for (const c of cadences) { if (!m.has(c.site)) m.set(c.site, []); @@ -328,19 +472,17 @@ export function CadencierClient({ return (
-

- Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence - (toutes les X semaines). L'échéance est calculée à partir de la{" "} - dernière réception du fournisseur sur le magasin. -

- {SITES.map(({ code, label }) => ( - +
+ +

+ Le cadencier vous rappelle quand commander : pour chaque fournisseur suivi, + choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "} + dernière réception du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les + 7 jours qui précèdent, puis à « À commander » le jour venu. +

+
+ {MAGASINS.map((m) => ( + ))}
); diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx index f433855..6b0491a 100644 --- a/src/app/(dashboard)/commandes-auto/client.tsx +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -1,199 +1,187 @@ "use client"; -import { useMemo, useState } from "react"; -import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react"; +import { useMemo } from "react"; +import { ShoppingCart } from "lucide-react"; import type { PgCommandeAutoRow } from "./page"; +import { Badge, type Ton } from "@/components/ui/badge"; +import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; +import { EmptyState } from "@/components/ui/states"; +import { Terme } from "@/components/ui/tooltip"; +import { fmtEntier, fmtEur2 } from "@/lib/format"; +import { MAGASINS, nomMagasin } from "@/lib/magasins"; -type SortDir = "asc" | "desc"; +type EtatFranco = "oui" | "non" | "inconnu"; -const SITES: { code: string; label: string }[] = [ - { code: "292", label: "292 — Frouard / Nancy" }, - { code: "579", label: "579 — Houdemont" }, +/** + * Franco atteint ? Sans montant de franco connu (0), on ne peut pas répondre, + * quel que soit l'indicateur renvoyé par l'API. + */ +function etatFranco(r: PgCommandeAutoRow): EtatFranco { + if (!(Number(r.franco) > 0)) return "inconnu"; + if (r.franco_atteint === true) return "oui"; + if (r.franco_atteint === false) return "non"; + return "inconnu"; +} + +const ETATS_FRANCO: Record = { + oui: { libelle: "Oui", ton: "succes" }, + non: { libelle: "Non", ton: "alerte" }, + inconnu: { libelle: "Inconnu", ton: "neutre" }, +}; + +function explicationFranco(r: PgCommandeAutoRow): string { + switch (etatFranco(r)) { + case "oui": + return "Le montant proposé atteint le franco : livraison sans frais de port."; + case "non": + return "Le montant proposé n'atteint pas le franco : des frais de port peuvent s'appliquer."; + default: + return Number(r.franco) > 0 + ? "Le franco est connu, mais FF n'indique pas s'il est atteint." + : "Aucun montant de franco connu pour ce fournisseur."; + } +} + +/** Écart entre le montant proposé et le franco, toujours positif. */ +function ecartFranco(r: PgCommandeAutoRow): number { + return Math.abs(Number(r.ecart_franco)) || Math.abs(Number(r.franco) - Number(r.montant_cde)) || 0; +} + +function EcartFranco({ r }: { r: PgCommandeAutoRow }) { + const etat = etatFranco(r); + const ecart = ecartFranco(r); + if (etat === "non") { + return Il manque {fmtEur2(ecart)}; + } + if (etat === "oui") { + return {ecart > 0 ? `Dépassé de ${fmtEur2(ecart)}` : "Atteint"}; + } + return —; +} + +const somme = (rows: readonly PgCommandeAutoRow[], valeur: (r: PgCommandeAutoRow) => number) => + rows.reduce((s, r) => s + (Number(valeur(r)) || 0), 0); + +const COLONNES: DataColumn[] = [ + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.nomfou, + cell: (r) => ( +
+
{r.nomfou || "Fournisseur sans nom"}
+ {r.codefou &&
Code {r.codefou}
} +
+ ), + footer: (rows) => `Total · ${fmtEntier(rows.length)} fournisseur${rows.length > 1 ? "s" : ""}`, + }, + { + id: "articles", + header: "Nb articles", + align: "right", + sortValue: (r) => Number(r.nb_articles), + cell: (r) => fmtEntier(Number(r.nb_articles)), + footer: (rows) => fmtEntier(somme(rows, (r) => r.nb_articles)), + }, + { + id: "montant", + header: "Montant proposé", + hint: "Montant hors taxes de la commande automatique proposée par FF.", + align: "right", + sortValue: (r) => Number(r.montant_cde), + cell: (r) => {fmtEur2(Number(r.montant_cde))}, + footer: (rows) => fmtEur2(somme(rows, (r) => r.montant_cde)), + }, + { + id: "franco", + header: , + align: "right", + sortValue: (r) => (Number(r.franco) > 0 ? Number(r.franco) : null), + cell: (r) => + Number(r.franco) > 0 ? ( + fmtEur2(Number(r.franco)) + ) : ( + — + ), + }, + { + id: "atteint", + header: "Franco atteint", + align: "center", + // Oui avant Non, inconnus toujours en fin de liste. + sortValue: (r) => { + const etat = etatFranco(r); + return etat === "oui" ? 1 : etat === "non" ? 0 : null; + }, + cell: (r) => { + const { libelle, ton } = ETATS_FRANCO[etatFranco(r)]; + return {libelle}; + }, + }, + { + id: "ecart", + header: "Écart au franco", + hint: "Ce qu'il manque pour atteindre le franco, ou de combien il est dépassé.", + align: "right", + sortValue: (r) => { + const etat = etatFranco(r); + if (etat === "inconnu") return null; + return etat === "oui" ? ecartFranco(r) : -ecartFranco(r); + }, + cell: (r) => , + }, ]; -function fmt(n: number) { - return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; -} +const FILTRES: DataFilter[] = [ + { + id: "atteint", + label: "Franco atteint", + valueOf: (r) => ETATS_FRANCO[etatFranco(r)].libelle, + allLabel: "Tous", + }, +]; -function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sortDir: SortDir }) { - if (col !== sortKey) return ; - return sortDir === "asc" - ? - : ; -} - -type SK = keyof PgCommandeAutoRow; -const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde"]; - -function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) { - // franco === 0 : aucune donnée franco disponible - // atteint === null ET franco === 0 : pas de franco (N/A ou données manquantes) - // atteint === null ET franco > 0 : franco connu mais statut non déterminé → on affiche quand même le montant - if (franco === 0) { - return ( - - Pas de franco - - ); - } - if (atteint === null) { - // Franco connu mais statut non calculé par l'API - return ( - - N/A ({fmt(franco)}) - - ); - } - if (atteint) { - return ( - - Atteint - - ); - } - return ( - - −{fmt(Math.abs(ecart))} manquant - - ); -} - -function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) { - const [sortKey, setSortKey] = useState("nomfou"); - const [sortDir, setSortDir] = useState("asc"); - const [search, setSearch] = useState(""); - - function handleSort(key: SK) { - if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); - else { setSortKey(key); setSortDir("asc"); } - } - - const filtered = useMemo(() => { - const q = search.toLowerCase(); - return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q)); - }, [rows, search]); - - const sorted = useMemo(() => [...filtered].sort((a, b) => { - const av = a[sortKey]; - const bv = b[sortKey]; - if (NUMERIC.includes(sortKey)) { - return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); - } - return sortDir === "asc" - ? String(av ?? "").localeCompare(String(bv ?? ""), "fr") - : String(bv ?? "").localeCompare(String(av ?? ""), "fr"); - }), [filtered, sortKey, sortDir]); - - const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length; - const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0); - - const th = (key: SK, label: string, right = false) => ( - handleSort(key)} - > - {label} - - ); +function SectionMagasin({ nom, rows }: { nom: string; rows: PgCommandeAutoRow[] }) { + const nbAtteint = rows.filter((r) => etatFranco(r) === "oui").length; + const totalMontant = somme(rows, (r) => r.montant_cde); return ( -
- {/* En-tête magasin */} -
-
-

{label}

- - {rows.length} fournisseur{rows.length !== 1 ? "s" : ""} - - {nbFrancoAtteint > 0 && ( - - {nbFrancoAtteint} franco atteint - - )} - - Total : {fmt(totalMontant)} - -
- setSearch(e.target.value)} - 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 min-w-[220px]" - /> +
+
+

{nom}

+ + {fmtEntier(rows.length)} fournisseur{rows.length > 1 ? "s" : ""} + + {nbAtteint > 0 && ( + + {fmtEntier(nbAtteint)} franco{nbAtteint > 1 ? "s" : ""} atteint{nbAtteint > 1 ? "s" : ""} + + )} + + Montant total proposé :{" "} + {fmtEur2(totalMontant)} +
- - {/* Tableau */} -
- - - - {th("codefou", "Code")} - {th("nomfou", "Fournisseur")} - {th("nb_articles", "Articles", true)} - {th("montant_cde", "Montant CDE auto", true)} - {th("franco", "Franco", true)} - - - - - - {sorted.length === 0 && ( - - - - )} - {sorted.map(row => { - const francoAtteint = row.franco_atteint === true; - const hasFranco = row.franco > 0 && row.franco_atteint !== null; - return ( - - - - - - - - - ); - })} - -
- Statut franco -
- Aucun fournisseur en commande automatique -
{row.codefou}{row.nomfou}{row.nb_articles} - {fmt(Number(row.montant_cde))} - - {row.franco > 0 ? fmt(Number(row.franco)) : —} - - -
-
-
+ `${r.site}-${r.codefou}`} + searchIn={(r) => [r.nomfou, r.codefou]} + searchPlaceholder="Rechercher un fournisseur…" + filters={FILTRES} + pageSize={0} + unite="fournisseurs" + showFooter + initialSort={{ id: "fournisseur", dir: "asc" }} + emptyTitle="Aucune proposition pour ce magasin" + emptyDescription="FF ne propose aucune commande automatique pour ce magasin en ce moment." + /> +
); } export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) { - const bySite = useMemo(() => { + const parMagasin = useMemo(() => { const map = new Map(); for (const r of rows) { if (!map.has(r.site)) map.set(r.site, []); @@ -204,20 +192,19 @@ export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) { if (rows.length === 0) { return ( -
- Aucune commande automatique trouvée. -
+ ); } return ( -
- {SITES.map(({ code, label }) => { - const siteRows = bySite.get(code) ?? []; - return ( - - ); - })} +
+ {MAGASINS.map((m) => ( + + ))}
); } diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index 3298dc4..f74f669 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -1,14 +1,22 @@ +import type { Metadata } from "next"; +import { ShoppingCart } from "lucide-react"; import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions"; -import { CommandesAutoTabs } from "./tabs"; +import { PageHeader } from "@/components/ui/page-header"; +import { CommandesAutoTabs, type Onglet } from "./tabs"; import { cachedFF } from "@/lib/ff-cache"; export type { PgCommandeAutoRow }; +export const metadata: Metadata = { title: "Commandes fournisseurs" }; + // Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build. export const dynamic = "force-dynamic"; -export default async function CommandesAutoPage() { +export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) { + const { onglet } = await props.searchParams; + const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions"; + const [rows, cadences, fournisseurs] = await Promise.all([ // Propositions de l'API FF (un appel, plus un par fournisseur sans franco) : // gardées 2 minutes, pour que chaque modification du cadencier — qui @@ -22,11 +30,13 @@ export default async function CommandesAutoPage() { ]); return ( -
-
-

Commandes automatiques

- -
+
+ +
); } diff --git a/src/app/(dashboard)/commandes-auto/tabs.tsx b/src/app/(dashboard)/commandes-auto/tabs.tsx index 4bb8e84..4ff1652 100644 --- a/src/app/(dashboard)/commandes-auto/tabs.tsx +++ b/src/app/(dashboard)/commandes-auto/tabs.tsx @@ -1,56 +1,51 @@ "use client"; -import { useState } from "react"; import { ShoppingCart, CalendarClock } from "lucide-react"; +import { Tabs, useUrlTab } from "@/components/ui/tabs"; import { CommandesAutoClient } from "./client"; import { CadencierClient } from "./cadencier-client"; import type { PgCommandeAutoRow } from "./page"; import type { CadenceView } from "@/features/commandes-auto/actions"; -type TabKey = "propositions" | "cadencier"; +export type Onglet = "propositions" | "cadencier"; export function CommandesAutoTabs({ + initial, rows, cadences, fournisseurs, }: { + initial: Onglet; rows: PgCommandeAutoRow[]; cadences: CadenceView[]; fournisseurs: { code: string; nom: string }[]; }) { - const [tab, setTab] = useState("propositions"); + const [onglet, setOnglet] = useUrlTab("onglet", initial); const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length; - const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => ( - - ); + const onglets = [ + { value: "propositions", label: "Propositions de commande", icon: ShoppingCart }, + { + value: "cadencier", + label: ( + <> + Rythme de commande + {nbACommander > 0 && (commandes à passer)} + + ), + icon: CalendarClock, + // Pastille rouge : nombre de commandes à passer (masquée s'il n'y en a aucune). + count: nbACommander > 0 ? nbACommander : undefined, + alert: true, + }, + ] as const; return (
-
- {tabBtn("propositions", "Propositions", ShoppingCart)} - {tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)} -
+ - {tab === "propositions" ? ( + {onglet === "propositions" ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 40cf142..82ea4d4 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,759 +1,205 @@ -import { pgGetDashboardData, type DashboardTopItem, type DashboardSiteStats, type DashboardSiteTop10 } from "@/lib/pg-ff-client"; +import type { Metadata } from "next"; +import Link from "next/link"; import { unstable_cache } from "next/cache"; +import { BarChart3, Euro, House, Receipt, ShoppingBasket, ShoppingCart, Store } from "lucide-react"; +import { pgGetDashboardData, type DashboardSiteStats, type DashboardSiteTop10, type DashboardTopItem } from "@/lib/pg-ff-client"; +import { PageHeader } from "@/components/ui/page-header"; +import { StatCard } from "@/components/ui/stat-card"; +import { Card, CardHeader } from "@/components/ui/card"; +import { Badge, DeltaBadge } from "@/components/ui/badge"; +import { EmptyState, ErrorState } from "@/components/ui/states"; +import { Terme } from "@/components/ui/tooltip"; +import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; + +export const metadata: Metadata = { title: "Accueil" }; const getCachedDashboardData = unstable_cache( pgGetDashboardData, ["dashboard-data"], { revalidate: 600 } // 10 minutes ); -import { TrendingUp, TrendingDown, Minus, Store, Euro, Users, ShoppingCart, Award, BarChart3 } from "lucide-react"; // --------------------------------------------------------------------------- -// Helpers +// Calculs // --------------------------------------------------------------------------- -const SITE_NAMES: Record = { - "F01": "Frouard", - "F02": "Houdemont", - "292": "Frouard", - "579": "Houdemont", -}; - -function fmtEur(n: number): string { - const abs = Math.abs(n); - if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} M€`; - return `${n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; +/** Évolution en % ; `null` quand il n'y a pas de base de comparaison. */ +function evolution(actuel: number, precedent: number): number | null { + if (!precedent) return null; + return ((actuel - precedent) / Math.abs(precedent)) * 100; } -function fmtQte(n: number): string { - return n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +function panierMoyen(ca: number, tickets: number): number | null { + return tickets > 0 ? ca / tickets : null; } -function fmtPct(n: number, signed = true): string { - const s = signed && n > 0 ? "+" : ""; - return `${s}${n.toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; -} - -function delta(current: number, previous: number): number | null { - if (!previous || previous === 0) return null; - return ((current - previous) / Math.abs(previous)) * 100; -} - -function dateLabel(iso: string): string { - const [y, m, d] = iso.split("-"); - return `${d}/${m}/${y}`; +/** « 2026-10-02 » → « vendredi 2 octobre 2026 ». */ +function dateLongue(iso: string): string { + const d = new Date(`${iso}T12:00:00`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", year: "numeric" }); } // --------------------------------------------------------------------------- -// Design tokens (inline color helpers) +// Blocs // --------------------------------------------------------------------------- -const clrPositive = "#22c55e"; -const clrNegative = "#ef4444"; -const clrGold = "#f59e0b"; -const clrSilver = "#94a3b8"; -const clrBronze = "#cd7c2b"; - -const tableAccents = { - ca: { bar: "#3b82f6", header: "rgba(59,130,246,0.12)", border: "rgba(59,130,246,0.3)", label: "#60a5fa" }, - qte: { bar: "#8b5cf6", header: "rgba(139,92,246,0.12)", border: "rgba(139,92,246,0.3)", label: "#a78bfa" }, - marge: { bar: "#10b981", header: "rgba(16,185,129,0.12)", border: "rgba(16,185,129,0.3)", label: "#34d399" }, -}; - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function DeltaBadge({ pct, large = false }: { pct: number | null; large?: boolean }) { - if (pct === null) return —; - const isPos = pct >= 0; - const Icon = pct > 0.5 ? TrendingUp : pct < -0.5 ? TrendingDown : Minus; - const color = isPos ? clrPositive : clrNegative; - const bg = isPos ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)"; - const borderClr = isPos ? "rgba(34,197,94,0.25)" : "rgba(239,68,68,0.25)"; - return ( - - - {fmtPct(pct)} - - ); -} - -function NetworkKpiCard({ - label, value, valueSub, delta: d, n1Value, n1Label, dateHier, dateN1, icon: Icon, accentColor, -}: { +function Indicateur({ label, valeur, valeurN1, delta }: { label: string; - value: string; - valueSub?: string; - delta?: number | null; - n1Value: string; - n1Label: string; - dateHier: string; - dateN1: string; - icon: React.ComponentType<{ style?: React.CSSProperties }>; - accentColor: string; + valeur: string; + valeurN1: string | null; + delta: number | null; }) { return ( -
- {/* Decorative glow */} -
- -
-
- - {label} - -
- - {value} - - {valueSub && ( - {valueSub} - )} -
-
-
- -
-
- - {/* Delta badge */} - {d !== undefined && ( -
- - - vs même jour N-1 - -
- )} - - {/* Divider */} -
- - {/* N-1 comparison row */} -
-
- - Hier — {dateHier} - - - {value} - -
-
-
- - N-1 — {dateN1} - - - {n1Value} - -
+
+
{label}
+
{valeur}
+
+ + {valeurN1 && N-1 : {valeurN1}}
); } -function StoreCard({ site, dateHier, dateN1 }: { site: DashboardSiteStats; dateHier: string; dateN1: string }) { - const name = SITE_NAMES[site.site] ?? site.site; - const dCa = delta(site.ca_hier, site.ca_n1); - const dTickets = delta(site.tickets_hier, site.tickets_n1); - const isPos = dCa !== null && dCa >= 0; - +function CarteMagasin({ site }: { site: DashboardSiteStats }) { + const panier = panierMoyen(site.ca_hier, site.tickets_hier); + const panierN1 = panierMoyen(site.ca_n1, site.tickets_n1); return ( -
- {/* Top accent bar */} -
- -
- {/* Store header */} -
-
-
- -
-
-
- {name} -
-
- Site {site.site} -
-
-
-
- - {/* Metrics row */} -
- {/* CA */} -
-
- - - Chiffre d'affaires - -
-
-
- {fmtEur(site.ca_hier)} -
- -
-
- N-1 : - - {site.ca_n1 > 0 ? fmtEur(site.ca_n1) : "—"} - -
-
- - {/* Tickets */} -
-
- - - Trafic tickets - -
-
- {fmtQte(site.tickets_hier)} -
-
- - {site.tickets_n1 > 0 && ( - - N-1 : {fmtQte(site.tickets_n1)} - - )} -
-
-
- - {/* Date footer */} -
- - Hier : {dateLabel(dateHier)} - - - N-1 : {dateLabel(dateN1)} - -
+ + {nomMagasin(site.site)}} + /> +
+ 0 ? fmtEur0(site.ca_n1) : null} + delta={evolution(site.ca_hier, site.ca_n1)} + /> + 0 ? fmtEntier(site.tickets_n1) : null} + delta={evolution(site.tickets_hier, site.tickets_n1)} + /> +
-
+ ); } -function RankBadge({ rank }: { rank: number }) { - if (rank === 0) return ( - 1 - ); - if (rank === 1) return ( - 2 - ); - if (rank === 2) return ( - 3 - ); - return ( - {rank + 1} - ); +type Critere = "ca" | "qte" | "marge"; + +const CRITERES: Record = { + ca: { titre: "Meilleur chiffre d'affaires", icone: Euro }, + qte: { titre: "Plus grandes quantités", icone: ShoppingCart }, + marge: { titre: "Meilleure marge", icone: BarChart3 }, +}; + +function valeurPrincipale(item: DashboardTopItem, critere: Critere): string { + if (critere === "qte") return `${fmtEntier(item.qte)} vendus`; + if (critere === "marge") return fmtEur0(item.marge); + return fmtEur0(item.ca); } -function Top10Table({ title, items, sortKey, accent }: { - title: string; - items: DashboardTopItem[]; - sortKey: "ca" | "qte" | "marge"; - accent: typeof tableAccents.ca; -}) { +function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Critere }) { + const { titre, icone: Icone } = CRITERES[critere]; return ( -
- {/* ── Header strip ── */} -
-
- {sortKey === "ca" && } - {sortKey === "qte" && } - {sortKey === "marge" && } -
- - {title} - - {/* column labels */} -
- {sortKey === "ca" && ( - <> - QTÉ - CA - - )} - {sortKey === "qte" && ( - QTÉ - )} - {sortKey === "marge" && ( - MARGE - )} -
+ +
+ +

{titre}

- - {/* ── Rows ── */} {items.length === 0 ? ( -
- Aucune donnée -
+

Aucune vente enregistrée.

) : ( - items.map((item, i) => { - const txMarge = item.ca > 0 ? (item.marge / item.ca) * 100 : 0; - const margeColor = txMarge > 25 ? "#22c55e" : txMarge > 15 ? "#f59e0b" : "var(--text-muted)"; - const margeBg = txMarge > 25 ? "rgba(34,197,94,0.1)" : txMarge > 15 ? "rgba(245,158,11,0.1)" : "rgba(100,116,139,0.1)"; - const margeBorder = txMarge > 25 ? "rgba(34,197,94,0.25)" : txMarge > 15 ? "rgba(245,158,11,0.25)" : "rgba(100,116,139,0.18)"; - const isEven = i % 2 === 0; - const isTopThree = i < 3; - - return ( -
- {/* ── Primary row: rank + name + main value ── */} -
- - +
    + {items.map((item, i) => { + const taux = item.ca > 0 ? (item.marge / item.ca) * 100 : null; + return ( +
  1. - {item.libelle1 ?? item.codein} + {i + 1} - - {/* Main sort-key value */} - - {sortKey === "qte" - ? `${fmtQte(item.qte)} pcs` - : sortKey === "marge" - ? fmtEur(item.marge) - : fmtEur(item.ca)} - -
- - {/* ── Tertiary row: fournisseur + stock ── */} -
- - {item.fournisseur} - - - Stk {fmtQte(item.stock)} - -
- - {/* ── Secondary info row ── */} -
- {/* For CA table: show qte + marge */} - {sortKey === "ca" && ( - <> - - {fmtQte(item.qte)} pcs +
+
+ + {item.libelle1 ?? item.codein} + + + {valeurPrincipale(item, critere)} - │ - - mg {fmtEur(item.marge)} +
+
+ {item.fournisseur} + {critere !== "ca" && CA {fmtEur0(item.ca)}} + {critere !== "qte" && {fmtEntier(item.qte)} vendus} + + Stock {fmtEntier(item.stock)} - - )} - - {/* For QTE table: show CA + marge */} - {sortKey === "qte" && ( - <> - - CA {fmtEur(item.ca)} - - │ - - mg {fmtEur(item.marge)} - - - )} - - {/* For Marge table: show CA + qte */} - {sortKey === "marge" && ( - <> - - CA {fmtEur(item.ca)} - - │ - - {fmtQte(item.qte)} pcs - - - )} - - {/* Taux de marge pill — always shown */} - - {fmtPct(txMarge, false)} - -
-
- ); - }) + {taux != null && ( + + Marge {fmtDecimal1(taux)} % + + )} +
+
+ + ); + })} + )} -
+ ); } -function SiteHitParade({ siteData, dateHier }: { siteData: DashboardSiteTop10; dateHier: string }) { - const name = SITE_NAMES[siteData.site] ?? siteData.site; - +function Top10Magasin({ siteData }: { siteData: DashboardSiteTop10 }) { return ( -
- {/* ── Section header ── */} -
- {/* subtle glow behind icon */} -
- -
- -
- -
-
-

- Hit Parade -

- - {name} - -
-

- Données du {dateLabel(dateHier)} · Site {siteData.site} -  · Top 10 produits par CA, Quantité et Marge -

-
- - {/* Store badge */} -
- - {name} -
+
+

+ + {nomMagasin(siteData.site)} +

+
+ + +
- - {/* ── Three tables side by side ── */} -
- - - -
-
+ ); } -// --------------------------------------------------------------------------- -// Section divider -// --------------------------------------------------------------------------- - -function SectionHeader({ label, sub }: { label: string; sub?: string }) { +function TitreSection({ titre, description }: { titre: string; description?: React.ReactNode }) { return ( -
-
-

- {label} -

- {sub &&

{sub}

} -
+
+

{titre}

+ {description &&

{description}

}
); } // --------------------------------------------------------------------------- -// Page principale +// Page // --------------------------------------------------------------------------- export default async function DashboardPage() { @@ -761,32 +207,15 @@ export default async function DashboardPage() { try { data = await getCachedDashboardData(); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); console.error("[Dashboard] pgGetDashboardData failed:", e); return ( -
-
- -
-

- Impossible de charger le tableau de bord -

-

- {msg.slice(0, 300)} -

+
+ +
); } @@ -797,131 +226,78 @@ export default async function DashboardPage() { const totalCaN1 = sites.reduce((s, r) => s + r.ca_n1, 0); const totalTickets = sites.reduce((s, r) => s + r.tickets_hier, 0); const totalTicketsN1 = sites.reduce((s, r) => s + r.tickets_n1, 0); - - const dCaReseau = delta(totalCa, totalCaN1); - const dTicketsReseau = delta(totalTickets, totalTicketsN1); - - const dHier = dateLabel(dateHier); - const dN1 = dateN1 ? dateLabel(dateN1) : "—"; + const panier = panierMoyen(totalCa, totalTickets); + const panierN1 = panierMoyen(totalCaN1, totalTicketsN1); return ( -
+
+ + Les ventes de {dateLongue(dateHier)} + {dateN1 && <>, comparées au même jour de la semaine l'an dernier ({dateLongue(dateN1)})}. + + } + actions={Données mises à jour chaque nuit} + /> - {/* ── PAGE HEADER ─────────────────────────────────────────────── */} -
- {/* subtle background pattern */} -
+ {sites.length === 0 ? ( + + ) : ( +
+
+ Total de nos magasins, évolution par rapport à .} + /> +
+ 0 ? `N-1 : ${fmtEur0(totalCaN1)}` : undefined} + icon={Euro} + /> + 0 ? `N-1 : ${fmtEntier(totalTicketsN1)}` : undefined} + icon={Receipt} + /> + +
+
-
-

- Tableau de Bord -

-

- Données du{" "} - {dHier} - {" · "} - Comparaison vs même jour de semaine N-1{" "} - ({dN1}) -

-
+
+ +
+ {sites.map((s) => )} +
+
-
- - - {sites.length} magasin{sites.length > 1 ? "s" : ""} actif{sites.length > 1 ? "s" : ""} - -
-
- - {/* ── KPI RÉSEAU ──────────────────────────────────────────────── */} -
- - -
- - -
-
- - {/* ── PAR MAGASIN ─────────────────────────────────────────────── */} - {sites.length > 0 && ( -
- SITE_NAMES[s.site] ?? s.site).join(" · ")}`} /> - -
- {sites.map(s => ( - - ))} -
-
- )} - - {/* ── HIT PARADE ──────────────────────────────────────────────── */} - {top10BySite.length > 0 && ( -
- - -
- {top10BySite.map((s, idx) => ( -
- {idx > 0 && ( -
- )} - + {top10BySite.length > 0 && ( +
+ +
+ {top10BySite.map((s) => )}
- ))} -
-
- )} - - {/* ── EMPTY STATE ─────────────────────────────────────────────── */} - {sites.length === 0 && ( -
- Aucune donnée disponible pour hier. + + )}
)}
diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 32be563..b8d30a3 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -1,7 +1,11 @@ +import type { Metadata } from "next"; +import { auth } from "@/lib/auth"; import { getFournisseurs, getMagasins } from "@/features/grid/actions"; import { GridClient } from "@/features/grid/components/grid-client"; import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; +export const metadata: Metadata = { title: "Révision d'assortiment" }; + interface GridPageProps { searchParams: Promise<{ fournisseur?: string; @@ -23,11 +27,13 @@ export default async function GridPage({ searchParams }: GridPageProps) { code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null, }; - // 1. Fetch available suppliers & stores - const [fournisseurs, magasins] = await Promise.all([ + // 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur) + const [fournisseurs, magasins, session] = await Promise.all([ getFournisseurs(), getMagasins(), + auth(), ]); + const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; // 2. If no supplier selected, show compact selection UI if (!codeFournisseur) { @@ -44,6 +50,7 @@ export default async function GridPage({ searchParams }: GridPageProps) { magasins={magasins} magasin={magasin} filters={filters} + isAdmin={isAdmin} /> ); } diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx index de939fe..d1fa6eb 100644 --- a/src/app/(dashboard)/hit-parade/client.tsx +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -1,381 +1,281 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, useMemo } from "react"; +import { useState, useTransition, type ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; +import { Input, Label } from "@/components/ui/form-controls"; +import { Button } from "@/components/ui/button"; +import { MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; +import { telechargerExcel } from "@/lib/export-excel"; +import { cn } from "@/lib/utils"; import type { HitParadePivotRow } from "./page"; -type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579"; -type SortDir = "desc" | "asc"; +type Ligne = HitParadePivotRow; -const DEFAULT_SORT: SortKey = "caTotal"; -const DEFAULT_DIR: SortDir = "desc"; +/** Champs numériques de la ligne pivotée. */ +type Champ = { [K in keyof Ligne]: Ligne[K] extends number ? K : never }[keyof Ligne]; -function formatCA(v: number) { - return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v); +interface Groupe { + id: string; + /** Nom affiché au-dessus des colonnes du groupe. */ + titre: string; + /** Nom complet, pour les infobulles et l'export. */ + nom: string; + qte: Champ; + ca: Champ; + marge: Champ; + stock: Champ; } -function formatQte(v: number) { - return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); + +/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */ +const CHAMPS_MAGASIN: Record> = { + "292": { qte: "qte292", ca: "ca292", marge: "marge292", stock: "stock292" }, + "579": { qte: "qte579", ca: "ca579", marge: "marge579", stock: "stock579" }, +}; + +const GROUPES: Groupe[] = [ + ...MAGASINS.filter(m => CHAMPS_MAGASIN[m.code]).map(m => ({ + id: m.code, + titre: nomMagasin(m.code), + nom: nomMagasin(m.code), + ...CHAMPS_MAGASIN[m.code], + })), + { id: "total", titre: "Total nos magasins", nom: "nos magasins", qte: "qteTotal", ca: "caTotal", marge: "margeTotal", stock: "stockTotal" }, +]; + +const somme = (rows: readonly Ligne[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0); + +/** Taux de marge en %, `null` sans chiffre d'affaires. */ +const tauxMarge = (ca: number, marge: number) => (ca > 0 ? (marge / ca) * 100 : null); + +const famille = (r: Ligne) => (r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "Sans famille"); + +const vide = —; + +function CelluleMarge({ taux }: { taux: number | null }) { + if (taux === null) return vide; + return {fmtDecimal1(taux)} %; } -function formatPct(ca: number, marge: number) { - if (ca === 0) return "—"; - return ((marge / ca) * 100).toFixed(1) + " %"; + +function CelluleStock({ v }: { v: number }) { + return ( + + {fmtEntier(v)} + + ); } +/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ +function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { + return ( + + {groupe} + {children} + + ); +} + +function colonnesGroupe(g: Groupe): DataColumn[] { + const total = g.id === "total"; + const bord = "border-l border-[var(--border)]"; + return [ + { + id: `${g.id}-qte`, + header: Qté, + hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`, + align: "right", + sortValue: r => r[g.qte], + cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide), + footer: rows => fmtEntier(somme(rows, g.qte)), + className: cn(bord, total && "font-semibold"), + headerClassName: bord, + }, + { + id: `${g.id}-ca`, + header: CA TTC, + hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`, + align: "right", + sortValue: r => r[g.ca], + cell: r => (r[g.ca] !== 0 ? fmtEur0(r[g.ca]) : vide), + footer: rows => fmtEur0(somme(rows, g.ca)), + className: cn("whitespace-nowrap", total && "font-semibold"), + }, + { + id: `${g.id}-marge`, + header: % marge, + hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`, + align: "right", + sortValue: r => tauxMarge(r[g.ca], r[g.marge]), + cell: r => , + footer: rows => , + className: "whitespace-nowrap", + }, + { + id: `${g.id}-stock`, + header: Stock, + hint: `Stock actuel (${g.nom}). En rouge : stock négatif`, + align: "right", + sortValue: r => r[g.stock], + cell: r => , + footer: rows => fmtEntier(somme(rows, g.stock)), + }, + ]; +} + +const COLONNES: DataColumn[] = [ + { + id: "codein", + header: "Code", + sortValue: r => r.codein, + cell: r => {r.codein}, + footer: () => "Total", + className: "whitespace-nowrap", + }, + { + id: "libelle", + header: "Désignation", + sortValue: r => r.libelle.trim(), + cell: r => {r.libelle.trim()}, + footer: rows => {fmtEntier(rows.length)} articles, + className: "min-w-[220px]", + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: r => r.fournisseur, + cell: r => {r.fournisseur}, + className: "whitespace-nowrap", + }, + { + id: "famille", + header: "Famille", + hint: "Famille de produits (nomenclature FF)", + sortValue: famille, + cell: r => + r.nomenclature_code ? ( + + {r.nomenclature_code} {r.nomenclature} + + ) : vide, + className: "min-w-[160px]", + }, + ...GROUPES.flatMap(colonnesGroupe), +]; + +const FILTRES: DataFilter[] = [ + { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" }, + { id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" }, +]; + +const recherche = (r: Ligne) => [r.codein, r.libelle]; + interface Props { dateDebut: string; dateFin: string; pivotted: HitParadePivotRow[]; } -function ColHeader({ - label, - sortable, - group, - sortKey, - sortDir, - onSort, -}: { - label: string; - sortable?: SortKey; - group?: "292" | "579" | "total"; - sortKey: SortKey; - sortDir: SortDir; - onSort: (key: SortKey) => void; -}) { - const isActive = sortable && sortKey === sortable; - const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : ""; - const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700"; - return ( - onSort(sortable) : undefined} - className={[ - "px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none", - sortable ? "cursor-pointer" : "", - isActive ? activeBg : `${groupBg} text-gray-600`, - ].join(" ")} - > - {label} - {sortable && ( - - {isActive ? (sortDir === "desc" ? "↓" : "↑") : ↕} - - )} - - ); -} - export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { const router = useRouter(); - const [sortKey, setSortKey] = useState(DEFAULT_SORT); - const [sortDir, setSortDir] = useState(DEFAULT_DIR); - const [filterFournisseur, setFilterFournisseur] = useState("Tous"); - const [filterNomenclature, setFilterNomenclature] = useState("Tous"); + const [navigation, demarrerNavigation] = useTransition(); // Dates locales pour les inputs (évite rechargement à chaque frappe) const [localDebut, setLocalDebut] = useState(dateDebut); const [localFin, setLocalFin] = useState(dateFin); - const fournisseurs = useMemo(() => { - const set = new Set(pivotted.map(r => r.fournisseur)); - return ["Tous", ...Array.from(set).sort((a, b) => a.localeCompare(b, "fr"))]; - }, [pivotted]); - - const nomenclatures = useMemo(() => { - const map = new Map(); // code → libelle - for (const r of pivotted) map.set(r.nomenclature_code, r.nomenclature); - const entries = Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0], "fr")); - return [{ code: "Tous", libelle: "Tous" }, ...entries.map(([code, libelle]) => ({ code, libelle }))]; - }, [pivotted]); - - function handleSort(key: SortKey) { - if (sortKey === key) { - setSortDir(d => d === "desc" ? "asc" : "desc"); - } else { - setSortKey(key); - setSortDir("desc"); - } - } - - function resetSort() { - setSortKey(DEFAULT_SORT); - setSortDir(DEFAULT_DIR); - } + const periodeInvalide = Boolean(localDebut && localFin && localDebut > localFin); function applyDates() { - if (localDebut && localFin) { - router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`); + if (localDebut && localFin && !periodeInvalide) { + demarrerNavigation(() => { + router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`); + }); } } - const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR; - - const filtered = useMemo(() => - pivotted.filter(r => - (filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) && - (filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature) - ), - [pivotted, filterFournisseur, filterNomenclature] - ); - - const sorted = useMemo(() => - [...filtered].sort((a, b) => { - const diff = a[sortKey] - b[sortKey]; - return sortDir === "desc" ? -diff : diff; - }), - [filtered, sortKey, sortDir] - ); - - const totals = useMemo(() => sorted.reduce( - (acc, r) => ({ - qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292, - qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579, - qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal, - stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal, - }), - { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 } - ), [sorted]); - - async function exportToExcel() { - // Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko. - const XLSX = await import("xlsx"); - const headers = [ - "Code", "Désignation", "Fournisseur", "Nomenclature", - "Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy", - "Qté Houdemont", "CA TTC Houdemont", "% Marge Houdemont", "Stock Houdemont", - "Qté Total", "CA TTC Total", "% Marge Total", "Stock Total", - ]; - + async function exportToExcel(rows: readonly Ligne[]) { const pct = (ca: number, marge: number) => ca === 0 ? 0 : Math.round((marge / ca) * 1000) / 10; + const nomExport = (g: Groupe) => (g.id === "total" ? "Total" : g.nom); - const dataRows = sorted.map(r => [ - r.codein, - r.libelle.trim(), - r.fournisseur, - r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "", - r.qte292, r.ca292, pct(r.ca292, r.marge292), r.stock292, - r.qte579, r.ca579, pct(r.ca579, r.marge579), r.stock579, - r.qteTotal, r.caTotal, pct(r.caTotal, r.margeTotal), r.stockTotal, - ]); - - const totalRow = [ - `TOTAL — ${sorted.length} articles`, "", "", "", - totals.qte292, totals.ca292, pct(totals.ca292, totals.marge292), totals.stock292, - totals.qte579, totals.ca579, pct(totals.ca579, totals.marge579), totals.stock579, - totals.qteTotal, totals.caTotal, pct(totals.caTotal, totals.margeTotal), totals.stockTotal, - ]; - - const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows, totalRow]); - - // Largeurs de colonnes - ws["!cols"] = [ - { wch: 12 }, { wch: 40 }, { wch: 25 }, { wch: 30 }, - { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 }, - { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 }, - { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 }, - ]; - - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Hit Parade"); - XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`); + await telechargerExcel({ + feuille: "Meilleures ventes", + fichier: `meilleures-ventes_${dateDebut}_${dateFin}`, + entetes: [ + "Code", "Désignation", "Fournisseur", "Nomenclature", + ...GROUPES.flatMap(g => [ + `Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`, + ]), + ], + largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], + lignes: rows.map(r => [ + r.codein, + r.libelle.trim(), + r.fournisseur, + r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "", + ...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]), + ]), + totaux: [ + `TOTAL — ${rows.length} articles`, "", "", "", + ...GROUPES.flatMap(g => { + const ca = somme(rows, g.ca); + return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)]; + }), + ], + }); } return ( -
- {/* Barre de contrôles */} -
- {/* Période */} -
-
- - setLocalDebut(e.target.value)} - className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" - /> -
-
- - setLocalFin(e.target.value)} - className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" - /> -
- +
+ {/* Période (relance le calcul côté serveur) */} +
+
+ + setLocalDebut(e.target.value)} + />
- -
- - {/* Filtre fournisseur */} -
- - +
+ + setLocalFin(e.target.value)} + />
- -
- - {/* Filtre nomenclature */} -
- - -
- -
- - - {sorted.length} produits - - - {isSorted && ( - + + {periodeInvalide && ( +

+ La date de début doit précéder la date de fin. +

)} - -
- {/* Tableau */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map((row) => ( - - - - - - - - - - - - - - - - - - - ))} - - - - - - - - - - - - - - - - - - -
CodeDésignationFournisseurNom. - Frouard / Nancy — 292 - - Houdemont — 579 - - Total Réseau -
% Marge% Marge% Marge
{row.codein}{row.libelle.trim()}{row.fournisseur} - {row.nomenclature_code - ? {row.nomenclature_code} - : — - } - - {row.qte292 > 0 ? formatQte(row.qte292) : —} - - {row.ca292 > 0 ? formatCA(row.ca292) : —} - - {row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""} - - {row.stock292 > 0 ? formatQte(row.stock292) : —} - - {row.qte579 > 0 ? formatQte(row.qte579) : —} - - {row.ca579 > 0 ? formatCA(row.ca579) : —} - - {row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""} - - {row.stock579 > 0 ? formatQte(row.stock579) : —} - - {row.qteTotal > 0 ? formatQte(row.qteTotal) : —} - - {row.caTotal > 0 ? formatCA(row.caTotal) : —} - - {row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""} - - {row.stockTotal > 0 ? formatQte(row.stockTotal) : 0} -
- TOTAL — {sorted.length} articles - {formatQte(totals.qte292)}{formatCA(totals.ca292)}{formatPct(totals.ca292, totals.marge292)}{formatQte(totals.stock292)}{formatQte(totals.qte579)}{formatCA(totals.ca579)}{formatPct(totals.ca579, totals.marge579)}{formatQte(totals.stock579)}{formatQte(totals.qteTotal)}{formatCA(totals.caTotal)}{formatPct(totals.caTotal, totals.margeTotal)}{formatQte(totals.stockTotal)}
+
+ + rows={pivotted} + columns={COLONNES} + rowKey={r => r.codein} + searchIn={recherche} + searchPlaceholder="Code ou désignation…" + filters={FILTRES} + pageSize={100} + unite="produits" + initialSort={{ id: "total-ca", dir: "desc" }} + showFooter + emptyTitle="Aucune vente sur cette période" + emptyDescription="Choisissez une autre période, puis cliquez sur « Appliquer »." + onExport={exportToExcel} + />
); diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx index 43d9d8b..be6ff04 100644 --- a/src/app/(dashboard)/hit-parade/page.tsx +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -1,6 +1,14 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { RotateCcw, Trophy } from "lucide-react"; import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client"; -import { HitParadeClient } from "./client"; import { cachedFF } from "@/lib/ff-cache"; +import { PageHeader } from "@/components/ui/page-header"; +import { ErrorState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; +import { HitParadeClient } from "./client"; + +export const metadata: Metadata = { title: "Meilleures ventes" }; export interface HitParadePivotRow { codein: string; @@ -81,19 +89,40 @@ export default async function HitParadePage(props: { const dateDebut = (searchParams.debut as string) || defaults.debut; const dateFin = (searchParams.fin as string) || defaults.fin; - const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin)); - const pivotted = pivotHitParade(rows); + let pivotted: HitParadePivotRow[] | null = null; + let erreur: string | null = null; + try { + const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin)); + pivotted = pivotHitParade(rows); + } catch (e) { + console.error("[hit-parade] chargement impossible :", e); + erreur = e instanceof Error ? e.message : String(e); + } return ( -
-
-

Hit Parade

+
+ + {pivotted ? ( -
+ ) : ( + + Réessayer + + } + /> + )}
); } diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index b011ce0..70695fb 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -1,77 +1,114 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, + ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, Scale, RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info, + type LucideIcon, } from "lucide-react"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts"; -import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months"; +import { + computeNetworkTrend, computeStoresSeries, trendLabel, NB_MAGASINS_RESEAU, type NetworkTrend, +} from "@/features/grid/lib/network-trend"; +import { formatMonthLabel, formatDate, ffMonthToQlik } from "@/features/grid/lib/months"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import type { ProduitFiche } from "@/features/produits/types"; +import { Card, CardContent, CardHeader, Field } from "@/components/ui/card"; +import { StatCard } from "@/components/ui/stat-card"; +import { Badge, GammeBadge, StoreBadge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Segmented } from "@/components/ui/tabs"; +import { EmptyState } from "@/components/ui/states"; +import { Terme, Tooltip } from "@/components/ui/tooltip"; +import { toast } from "@/components/ui/feedback"; +import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; +import { cn } from "@/lib/utils"; import { OpportunitesFamille } from "./opportunites"; -// ─── Formatteurs partagés ──────────────────────────────────────────────────── - -const fmtEur = (v: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v); -const fmtEur2 = (v: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v); -const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); -const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v); - // ─── Briques de présentation ───────────────────────────────────────────────── -function Card({ title, icon: Icon, children, action }: { - title: string; - icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; - children: React.ReactNode; - action?: React.ReactNode; +/** Bloc de la fiche : carte du thème, titre avec icône, actions à droite. */ +function Bloc({ titre, icone: Icone, description, actions, children }: { + titre: ReactNode; + icone: LucideIcon; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; }) { return ( -
-
-

- - {title} -

- {action} -
-
{children}
-
+ + + + {titre} + + } + description={description} + actions={actions} + /> + {children} + ); } -function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { +/** Libellé avec une explication libre au survol (comme ``, hors glossaire). */ +function Aide({ texte, children }: { texte: ReactNode; children: ReactNode }) { return ( -
-
- {label} -
-
- {value ?? "—"} -
+ + + {children} + + + + ); +} + +/** Sous-titre d'une partie de bloc. */ +function SousTitre({ titre, description }: { titre: ReactNode; description?: ReactNode }) { + return ( +
+

{titre}

+ {description &&

{description}

}
); } -function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) { - return ( -
-
- {label} -
-
- {value} -
- {hint &&
{hint}
} -
- ); +/** Classes des petits tableaux mensuels (en-tête et première colonne figée). */ +const TH_COL = "whitespace-nowrap px-2 py-2 text-right text-[13px] font-semibold text-[var(--text-secondary)]"; +const TH_COIN = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-elevated)] px-3 py-2 text-left text-[13px] font-semibold text-[var(--text-secondary)]"; +const TH_LIGNE = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-surface)] px-3 py-2 text-left font-medium text-[var(--text-secondary)]"; +const TD = "whitespace-nowrap px-2 py-2 text-right tabular-nums text-[var(--text-primary)]"; + +/** Couleur de la tendance, prise dans le thème (lisible en clair comme en sombre). */ +const COULEUR_TENDANCE: Record = { + up: "var(--accent-success)", + down: "var(--accent-error)", + flat: "var(--text-secondary)", +}; + +/** Couleur du verdict de l'indice de performance, prise dans le thème. */ +const COULEUR_VERDICT: Record = { + "Forte surperformance": "var(--accent-success)", + Surperformance: "var(--accent-success)", + "Dans la moyenne réseau": "var(--text-primary)", + "Sous-performance": "var(--accent-warning)", + "Forte sous-performance": "var(--accent-error)", +}; + +/** Période Qlik `"2025-09_2026-08"` → « de septembre 2025 à août 2026 ». */ +function libellePeriode(periode: string | null | undefined): string { + const m = /^(\d{4})-(\d{2})_(\d{4})-(\d{2})$/.exec(periode ?? ""); + if (!m) return periode ? `période ${periode}` : "12 derniers mois"; + const mois = (annee: string, num: string) => + new Date(Number(annee), Number(num) - 1, 1).toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); + return `de ${mois(m[1], m[2])} à ${mois(m[3], m[4])}`; } // ─── Détail mensuel réseau ─────────────────────────────────────────────────── @@ -89,15 +126,15 @@ const LIGNES_MENSUELLES_RESEAU: Array<{ get: (v: MoisReseau) => number | undefined; fmt: (n: number) => string; }> = [ - { label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) }, - { label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) }, - { label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) }, - { label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") }, - { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) }, - { label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` }, + { label: "Quantité vendue", get: (v) => v.qte, fmt: fmtEntier }, + { label: "Magasins vendeurs", get: (v) => v.nbMag, fmt: fmtEntier }, + { label: "Quantité par magasin", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: fmtDecimal1 }, + { label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtEntier }, + { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: fmtEur2 }, + { label: "Taux de marge", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDecimal1(n)} %` }, ]; -// ─── Bouton d'extraction Qlik pour un seul code centrale ───────────────────── +// ─── Mise à jour des données du réseau pour un seul code centrale ─────────── function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { const router = useRouter(); @@ -106,29 +143,52 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { onSuccess: () => router.refresh(), }); + // Un échec n'est notifié que s'il suit un clic ici : la reprise d'un ancien + // job au chargement de la fiche ne doit pas afficher de message d'erreur. + const demandeRef = useRef(false); + useEffect(() => { + if (status === "error" && demandeRef.current) { + demandeRef.current = false; + toast.erreur(`La mise à jour des données du réseau a échoué. ${message}`.trim()); + } else if (status === "success") { + demandeRef.current = false; + } + }, [status, message]); + + const lancer = () => { + demandeRef.current = true; + void start(); + }; + return ( -
- {message && ( - - {message} - - )} - + {status === "running" ? : } + {status === "running" ? "Mise à jour…" : "Mettre à jour les données du réseau"} +
); } @@ -137,7 +197,7 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) { const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; - const [magasin, setMagasin] = useState("TOTAL"); + const [magasin, setMagasin] = useState(CODE_TOUS_MAGASINS); const sitesDisponibles = useMemo(() => { const sites = new Set([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]); @@ -145,8 +205,9 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba }, [fiche.mensuelParSite, stock]); // Série affichée selon le magasin sélectionné. - const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); - const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); + const tous = magasin === CODE_TOUS_MAGASINS; + const serie = tous ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); + const totauxAffiches = tous ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); /** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */ const nbMagByMonth = useMemo(() => { @@ -171,7 +232,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const verdict = indiceVerdict(comparatif.indiceQte); const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0]; - /** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */ + /** Nom de fournisseur à afficher : notre catalogue d'abord, sinon celui du réseau. */ const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null; const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé"; @@ -180,353 +241,379 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null; const margeReseau = normalizeMargePct(reseau?.margePctReseau); + const lienGrille = (codefou: string) => `/grid?fournisseur=${encodeURIComponent(codefou)}&magasin=TOTAL`; + return ( -
- {/* En-tête */} +
+ {/* En-tête de la fiche */}
- - Retour aux résultats + + {backQuery ? "Retour aux résultats" : "Retour à la recherche"} -

- {titre} -

-

- {detail && <>{detail.codein}{" · "}} - {codeCentrale && <>centrale {codeCentrale}} - {fournisseurAffiche && <> · {fournisseurAffiche}} +

{titre}

+

+ {detail && ( + Code article {detail.codein} + )} + {codeCentrale && ( + Code centrale {codeCentrale} + )} + {fournisseurAffiche && Fournisseur : {fournisseurAffiche}}

{fournisseurPrincipal?.codefou && ( - - - Voir dans la Grille - + )}
{!detail && (
- +
- Ce produit est travaillé par le réseau mais n'est pas référencé dans notre catalogue. -
- Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous. + Ce produit est vendu dans le réseau mais n'est pas dans notre catalogue. +
+ Seules les données du réseau sont disponibles : pas de stock, de ventes ni de gamme chez nous.
)} - {/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */} - : undefined} + {/* ─── Réseau — la donnée qui motive la recherche ─────────────── */} + Ventes dans le réseau · 12 derniers mois} + icone={Globe} + actions={codeCentrale ? : undefined} > {!codeCentrale ? ( -

- Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik. +

+ Ce produit n'a pas de code centrale : il n'est pas suivi + par la centrale d'achat, il n'y a donc pas de données du réseau.

) : !reseau ? ( -
- -
- Aucune donnée réseau en cache pour ce produit. -
- Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes. -
-
-
+ ) : ( <> -
- + } + value={fmtEntier(reseau.nbMagasinsReseau)} + hint={`sur ${NB_MAGASINS_RESEAU}, soit ${fmtEntier((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100)} % du réseau`} /> - - + - - - - = 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined} + + } value={fmtEur0(reseau.caParMagasinReseau)} /> + Taux de marge} + value={ + margeReseau != null + ? {fmtDecimal1(margeReseau)} % + : "—" + } /> - = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} + + Tendance + + } + value={ + trend.pct != null + ? ( + + {trend.pct >= 0 ? "+" : ""}{fmtEntier(trend.pct * 100)} % + + ) + : "—" + } hint={trendLabel(trend.pct, trend.nouveau)} - color={TREND_COLOR[trend.direction]} />
-
- - +
+ {libelleReseau || "—"} + {fournisseurReseau || "—"}
-

- Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} · - {" "}mois en cours exclu (incomplet). +

+ Ventes {libellePeriode(reseau.periode)} · données du réseau mises à jour le {formatDate(reseau.fetchedAt)} · + {" "}le mois en cours n'est pas compté (incomplet).

{trend.hasData && ( -
-
- Tendance réseau — la qté/magasin d'abord, les volumes en contexte -
+
+ -
+ )} {/* Détail mensuel complet (CA, marge, magasins) si disponible */} {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && ( -
-
- Détail mensuel réseau -
- - - - - {months.map((m) => ( - - ))} - - - - {LIGNES_MENSUELLES_RESEAU.map((row) => ( - - - {months.map((m) => { - const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; - const v = cell ? row.get(cell) : undefined; - return ( - - ); - })} +
+ +
+
Mesure - {formatMonthLabel(m)} -
- {row.label} - - {v != null ? row.fmt(v) : "—"} -
+ + + + {months.map((m) => ( + + ))} - ))} - -
Mesure{formatMonthLabel(m)}
-

- Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité. + + + {LIGNES_MENSUELLES_RESEAU.map((row) => ( + + {row.label} + {months.map((m) => { + const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; + const v = cell ? row.get(cell) : undefined; + return ( + + {v != null ? row.fmt(v) : —} + + ); + })} + + ))} + + +

+

+ Pour certains mois, les données du réseau ne donnent que la quantité : le chiffre + d'affaires et la marge y sont alors absents.{" "} + + Pourquoi ? +

-
+ )} )} -
+ {/* ─── Fournisseur ─────────────────────────────────────────────── */} - + {fournisseurs.length === 0 ? ( -

+

{fournisseurReseau - ? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique {fournisseurReseau}. - : "Aucun fournisseur référencé pour ce produit."} + ? <>Aucun fournisseur dans notre catalogue. Dans les données du réseau, le fournisseur est {fournisseurReseau}. + : "Aucun fournisseur connu pour ce produit."}

) : (
- {fournisseurs.map((f) => ( -
-
-
- {f.nomfou} - {f.principal && · principal} +
    + {fournisseurs.map((f) => ( +
  • +
    +
    + {f.nomfou} + {f.principal && Fournisseur principal} +
    +
    + Code fournisseur {f.codefou} +
    -
    {f.codefou}
    -
-
- Réf. {f.reference || "—"} - PCB {f.pcb != null ? fmtQte(f.pcb) : "—"} - - Grille - -
-
- ))} +
+ Référence : {f.reference || "—"} + + : + {f.pcb != null ? fmtEntier(f.pcb) : "—"} + + + Réviser l'assortiment + +
+ + ))} + {fournisseurReseau && ( -

- Fournisseur côté Qlik : {fournisseurReseau} +

+ Fournisseur indiqué par les données du réseau : {fournisseurReseau}

)}
)} - + {/* ─── Identité catalogue (produits référencés uniquement) ──────── */} {detail && ( - -
- - - - - - - - - - - - - 0 ? fmtQte(commandesEnCours) : "—"} /> + +
+ }>{detail.codein} + }>{detail.code_centrale || "—"} + {fournisseurAffiche ?? "—"} + }>{detail.gtin || "—"} + {detail.reference || "—"} + }>{detail.pcb != null ? fmtEntier(detail.pcb) : "—"} + }> + {detail.libelle1nom ?? "—"} + {detail.libelle2 ?? "—"} + {detail.libelle3 ?? "—"} + {detail.pa != null ? fmtEur2(detail.pa) : "—"} + + Prix de vente minimum (centrale) + + } + > + {detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} + + Quantité en commande} + > + {commandesEnCours > 0 ? fmtEntier(commandesEnCours) : "—"} +
{gammes.length > 1 && ( -
-
- Historique de gamme -
-
+
+ +
    {gammes.slice(0, 10).map((g, i) => ( - {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} - {g.gamme_code} - + + ))} -
+
)} - + )} {/* ─── Stock ───────────────────────────────────────────────────── */} {detail && ( - + {stock.length === 0 ? ( -

Aucune ligne de stock pour ce produit.

+

Aucun stock enregistré pour ce produit dans nos magasins.

) : ( -
+
{stock.map((s) => ( -
-
- - {SITE_LABELS[s.site]?.nom ?? s.site} - - - {fmtQte(s.qte)} - +
+
+ +
+
Stock
+
+ {fmtEntier(s.qte)} +
+ {s.qte < 0 && } +
-
-
Dispo : {fmtQte(s.stockdispo)}
-
Valeur : {fmtEur(s.valstock)}
-
PRMP : {s.prmp > 0 ? fmtEur2(s.prmp) : "—"}
-
Dern. vente : {formatDate(s.dernierevente)}
-
Dern. réception : {formatDate(s.dernierereception)}
+
+ {fmtEntier(s.stockdispo)} + {fmtEur0(s.valstock)} + }>{s.prmp > 0 ? fmtEur2(s.prmp) : "—"} + {formatDate(s.dernierevente)} + {formatDate(s.dernierereception)}
))}
)} - + )} {/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */} {detail && ( - - {["TOTAL", ...sitesDisponibles].map((code) => ( - - ))} -
+ ({ value: code, label: nomMagasin(code, { court: true }) })), + ]} + value={magasin} + onChange={setMagasin} + /> } > -
- - - - + + + } value={fmtEur0(totauxAffiches.marge)} /> + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + value={ + totauxAffiches.ca > 0 + ? {fmtDecimal1(totauxAffiches.tauxMarge)} % + : "—" + } />
-
- - +
+
+ - + {months.map((m) => ( - + ))} - - + + {months.map((m) => ( - - - + + {months.map((m) => ( - ))} - - + + {months.map((m) => ( - ))} @@ -562,100 +645,119 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba serie[m]?.qte ?? 0)} - color="#0ea5e9" /> - + )} {/* ─── Comparatif local vs réseau ──────────────────────────────── */} {detail && comparatif.hasReseau && ( - -
- +
+ + Indice de performance + + } + value={ + comparatif.indiceQte != null + ? {fmtEntier(comparatif.indiceQte)} % + : "—" + } hint={verdict.label} - color={verdict.color} /> - 1 ? "s" : ""} · 12 mois`} /> - - Présence dans le réseau} + value={comparatif.tauxPresence != null ? `${fmtEntier(comparatif.tauxPresence * 100)} %` : "—"} + hint={`${fmtEntier(comparatif.nbMagasinsReseau)} magasins vendeurs sur ${NB_MAGASINS_RESEAU}`} />
-
-
-
- CA par magasin · indicatif -
-
- Nous : {fmtEur(comparatif.caLocalParMagasin)} - Réseau : {comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"} -
-
-
-
- Taux de marge · indicatif -
-
- Nous : {fmtDec(comparatif.tauxMargeLocal)} % - Réseau : {comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"} -
-
+
+ Chiffre d'affaires par magasin} + nous={fmtEur0(comparatif.caLocalParMagasin)} + reseau={comparatif.caReseauParMagasin != null ? fmtEur0(comparatif.caReseauParMagasin) : "—"} + /> + Taux de marge} + nous={{fmtDecimal1(comparatif.tauxMargeLocal)} %} + reseau={ + comparatif.tauxMargeReseau != null + ? {fmtDecimal1(comparatif.tauxMargeReseau)} % + : "—" + } + />
-

- L'indice compare les quantités par magasin, seule base commune fiable. - Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas - documentée — montants et marges sont donc donnés à titre indicatif. +

+ L'indice compare les quantités vendues par magasin, seule base commune fiable. + Notre chiffre d'affaires est TTC (caisses) ; la base de celui du réseau n'est pas connue : + montants et marges sont donc donnés à titre indicatif.

{comparatif.hasMonthly && ( -
-
- Quantité par magasin, mois par mois -
+
+ ffMonthToQlik(m.mois))} series={[ - { name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" }, - { name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" }, + { name: "Nous, par magasin", values: comparatif.monthly.map((m) => m.local), color: "var(--viz-1)" }, + { name: "Réseau, par magasin", values: comparatif.monthly.map((m) => m.reseau), color: "var(--viz-2)" }, ]} /> -
+ )} - + )} {/* ─── Opportunités de la même famille ─────────────────────────── */} {detail?.nom_no_id != null && ( - + - + )} {/* Sparkline discrète en pied de fiche, cohérente avec la Grille */} {trend.hasData && ( -
- Tendance réseau : +
+ Tendance dans le réseau :
)}
); } + +/** Deux valeurs côte à côte, nous et le réseau, à titre indicatif. */ +function Comparaison({ titre, nous, reseau }: { titre: ReactNode; nous: ReactNode; reseau: ReactNode }) { + return ( +
+
+ {titre} · indicatif +
+
+ Nous : {nous} + Réseau : {reseau} +
+
+ ); +} diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx index f1ef7e3..5594d65 100644 --- a/src/app/(dashboard)/produits/opportunites.tsx +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -2,11 +2,82 @@ import { useState } from "react"; import Link from "next/link"; -import { Loader2, TrendingUp } from "lucide-react"; +import { Loader2, RefreshCw, TrendingUp } from "lucide-react"; import type { PgOpportuniteRow } from "@/lib/pg-ff-client"; +import { Button } from "@/components/ui/button"; +import { DataTable, type DataColumn } from "@/components/ui/data-table"; +import { EmptyState, ErrorState } from "@/components/ui/states"; +import { GLOSSAIRE } from "@/lib/glossaire"; +import { fmtDecimal1, fmtEntier } from "@/lib/format"; -const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); -const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v); +const COLONNES: DataColumn[] = [ + { + id: "produit", + header: "Produit", + sortValue: (r) => r.libelle1 || r.codein, + cell: (r) => ( + + {r.libelle1 || r.codein} + + ), + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.fournisseur, + cell: (r) => {r.fournisseur}, + }, + { + id: "reseau", + header: "Réseau, par magasin", + hint: "Quantité vendue sur 12 mois par un magasin du réseau qui a ce produit.", + align: "right", + sortValue: (r) => r.qte_reseau_par_magasin, + cell: (r) => fmtDecimal1(r.qte_reseau_par_magasin), + }, + { + id: "nous", + header: "Nous, par magasin", + hint: "Quantité vendue sur 12 mois par chacun de nos magasins, en moyenne.", + align: "right", + sortValue: (r) => r.qte_locale_par_magasin, + cell: (r) => fmtDecimal1(r.qte_locale_par_magasin), + }, + { + id: "ecart", + header: "Écart", + hint: "Réseau par magasin moins nous par magasin. Positif : le réseau vend plus que nous.", + align: "right", + sortValue: (r) => r.ecart_par_magasin, + cell: (r) => { + const opportunite = r.ecart_par_magasin > 0; + return ( + + {opportunite ? "+" : ""}{fmtDecimal1(r.ecart_par_magasin)} + + ); + }, + }, + { + id: "magasins", + header: "Magasins vendeurs", + hint: GLOSSAIRE.presenceReseau.definition, + align: "right", + sortValue: (r) => r.nb_magasins_reseau, + cell: (r) => {fmtEntier(r.nb_magasins_reseau)}, + }, +]; + +const rechercherDans = (r: PgOpportuniteRow) => [r.libelle1, r.fournisseur, r.codein, r.code_centrale]; /** * Produits de la même famille que le produit affiché, classés par écart de @@ -45,17 +116,31 @@ export function OpportunitesFamille({ } if (rows === null) { + if (error && !loading) { + return ( + + Réessayer + + } + /> + ); + } return ( -
-

- Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} : - lesquelles le réseau vend bien et que nous vendons peu ? +

+

+ Comparez ce produit aux autres produits{familleLabel ? <> de la famille « {familleLabel} » : " de la même famille"} : + lesquels le réseau vend bien alors que nous les vendons peu ?

- - {error &&

{error}

} +
); } @@ -65,71 +150,31 @@ export function OpportunitesFamille({ if (others.length === 0) { return ( -

- Aucune autre référence de cette famille n'a de données réseau en cache. - Synchronisez d'autres fournisseurs pour enrichir la comparaison. -

+ ); } return ( -
-
-
- Mois - Mois - {formatMonthLabel(m)} - {formatMonthLabel(m)}
- Ventes -
Quantité vendue + ))}
- CA (TTC) -
Chiffre d'affaires TTC (€) - {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} + + {serie[m]?.ca ? fmtEntier(serie[m].ca) : —}
- Stock fin de mois -
Stock en fin de mois - {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} + + {serie[m]?.stockFinMois ? fmtEntier(serie[m].stockFinMois) : —}
- - - - - - - - - - - - {others.map((r) => { - const opportunite = r.ecart_par_magasin > 0; - return ( - - - - - - - - - ); - })} - -
ProduitFournisseurRéseau / mag.Nous / mag.ÉcartMagasins
- - {r.libelle1 || r.codein} - - {r.fournisseur} - {fmtDec(r.qte_reseau_par_magasin)} - - {fmtDec(r.qte_locale_par_magasin)} - - {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)} - - {fmtQte(r.nb_magasins_reseau)} -
-
-

- Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois. - Un écart positif signale une référence sous-exploitée chez nous. - Périmètre : les articles de notre catalogue ayant déjà des données réseau — - Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici. +

+ r.codein} + searchIn={rechercherDans} + searchPlaceholder="Filtrer les produits…" + pageSize={25} + unite={others.length > 1 ? "produits" : "produit"} + maxHeight="60vh" + /> +

+ Écart = quantité vendue par magasin dans le réseau − notre quantité vendue par magasin, sur 12 mois. + Un écart positif signale un produit sous-exploité chez nous. Seuls les produits de notre + catalogue qui ont déjà des données du réseau apparaissent ici : un produit absent de notre + catalogue ne peut pas y figurer.

); diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx index 59594d5..d9b4a75 100644 --- a/src/app/(dashboard)/produits/page.tsx +++ b/src/app/(dashboard)/produits/page.tsx @@ -1,8 +1,15 @@ +import type { Metadata } from "next"; +import { PackageSearch, SearchX } from "lucide-react"; import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/states"; +import { Terme } from "@/components/ui/tooltip"; import { ProduitSearchBar } from "./search-bar"; import { ProduitResults } from "./results"; import { ProduitFicheView } from "./fiche"; +export const metadata: Metadata = { title: "Recherche produit" }; + export const dynamic = "force-dynamic"; /** @@ -30,40 +37,44 @@ export default async function ProduitsPage(props: { const ficheDemandee = Boolean(codein || codeCentrale); return ( -
-
-

- Recherche produit -

-

- Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue — - performance réseau et ventes locales sur 12 mois glissants. -

-
+
+ + Cherchez un produit par son nom ou son code, puis comparez ses ventes à celles des + magasins du réseau. + + } + /> - {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} - +
+ {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} + - {ficheDemandee && !fiche && ( -
- Aucun produit trouvé pour{" "} - {codein - ? <>le code article {codein} - : <>le code centrale {codeCentrale}}. - {codeCentrale && !codein && ( -
- Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche. -
- )} -
- )} + {ficheDemandee && !fiche && ( + Aucun produit de notre catalogue ne porte le code article {codein}. + ) : ( + <> + Le code centrale {codeCentrale} n'est ni dans notre + catalogue, ni dans les données du réseau déjà chargées. Relancez une recherche par le nom + ou le code du produit. + + ) + } + /> + )} - {fiche && } + {fiche && } - {!ficheDemandee && } + {!ficheDemandee && } +
); } diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 4c1e823..0e65558 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -1,19 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react"; import { TrendSparkline } from "@/features/grid/components/network-charts"; -import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; +import { computeNetworkTrend, NB_MAGASINS_RESEAU, type NetworkTrend } from "@/features/grid/lib/network-trend"; import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; - -const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); -const fmtDec = (v: number, d = 1) => - new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v); -const fmtEur = (v: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v); -const fmtEur2 = (v: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v); +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { DataTable, type DataColumn } from "@/components/ui/data-table"; +import { EmptyState, ErrorState } from "@/components/ui/states"; +import { Terme } from "@/components/ui/tooltip"; +import { GLOSSAIRE } from "@/lib/glossaire"; +import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; /** Intervalle d'interrogation de l'état du job de recherche. */ const POLL_MS = 2000; @@ -57,6 +57,38 @@ async function lireJson(res: Response): Promise> { return data; } +/** + * Les messages du serveur nomment l'outil source (Qlik) : l'utilisateur lit + * « données du réseau », le message d'origine reste dans le détail technique. + */ +const mentionneOutil = (texte: string | null | undefined) => /qlik/i.test(texte ?? ""); + +function libelleEtape(etape: string | null): string { + if (!etape || mentionneOutil(etape)) return "Recherche dans les données du réseau…"; + return etape; +} + +/** Bandeau d'avertissement : explication en clair, détail technique replié. */ +function Avertissement({ children, detail }: { children: ReactNode; detail?: string | null }) { + return ( +
+ +
+ {children} + {detail && ( +
+ Détail technique +

{detail}

+
+ )} +
+
+ ); +} + /** * Résultats de recherche produit. * @@ -144,36 +176,31 @@ export function ProduitResults({ query }: { query: string }) { if (!query) { return ( -
- -

- Recherchez un produit dans le réseau -

-

- La recherche interroge Qlik Sense (les ~{NB_MAGASINS_RESEAU} magasins du réseau), - puis rapproche chaque produit trouvé de notre catalogue. - Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »). -

-
+ + Saisissez le nom d'un produit (ex. « poêle 28 ») ou son code centrale + (ex. « 10000167303 »). Vous verrez ses ventes dans les {NB_MAGASINS_RESEAU} magasins + du réseau, puis chez nous s'il est dans notre catalogue. + + } + /> ); } if (loading) { return (
- -

- {etape ?? "Interrogation de Qlik Sense…"} -

-

- Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. - Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte. + +

{libelleEtape(etape)}

+

+ Recherche des produits puis de leurs ventes sur les 12 derniers mois. + Cela peut prendre plus d'une minute : vous pouvez laisser la page ouverte.

); @@ -181,21 +208,20 @@ export function ProduitResults({ query }: { query: string }) { if (error) { return ( -
-
- -
-

- La recherche a échoué -

-

{error}

- -
-
-
+ + Réessayer + + } + /> ); } @@ -204,196 +230,227 @@ export function ProduitResults({ query }: { query: string }) { const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data; return ( -
-
-

- {source === "qlik" ? ( - - ) : ( - - )} - {rows.length} produit{rows.length > 1 ? "s" : ""} - {source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"} - {tronque && " (liste tronquée — affinez la recherche)"} - {" · "}{(dureeMs / 1000).toFixed(1)} s +

+
+

+ {source === "qlik" + ? + : } + + {fmtEntier(rows.length)}{" "} + produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} + {source === "qlik" ? " dans le réseau" : " dans notre catalogue"} + {tronque && " (liste limitée : précisez la recherche pour tout voir)"} + · recherche en {fmtDecimal1(dureeMs / 1000)} s +

- +
{source === "db" && ( -
- - - Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local - (les chiffres réseau affichés viennent du dernier cache). - {qlikError && {qlikError}} - -
+ + Les données du réseau n'ont pas pu être interrogées : seuls les produits de notre catalogue + sont affichés, avec les chiffres du réseau de la dernière mise à jour. + )} {source === "qlik" && qlikError && ( -
- - - {rows.length === 0 - ? qlikError - : `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`} - -
+ + {rows.length === 0 + ? "La recherche n'a pas pu être appliquée aux données du réseau. Essayez un terme plus précis (deux mots, ou le code centrale)." + : "Produits trouvés, mais leurs ventes dans le réseau n'ont pas pu être chargées."} + )} {rows.length === 0 ? ( -
- Aucun article du réseau ne correspond à « {query} ». -
+ Aucun produit ne correspond à « {query} »} + description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit." + /> ) : ( - + <> + +

+ Chiffres des magasins du réseau sur les 12 derniers mois + (mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche. +

+ )} {locauxHorsReseau.length > 0 && ( -
- - {locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue - sans correspondance réseau (pas de code centrale, ou non vendu par le réseau) +
+ + {fmtEntier(locauxHorsReseau.length)} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue + sans ventes connues dans le réseau (pas de code centrale, ou pas vendu par le réseau) -
-
    - {locauxHorsReseau.slice(0, 30).map((l) => ( -
  • - - {l.libelle1 || l.codein} - - - {" · "}{l.fournisseur}{" · "}{l.codein} - -
  • - ))} -
-
+
    + {locauxHorsReseau.slice(0, 30).map((l) => ( +
  • + + {l.libelle1 || l.codein} + + + {" · "}{l.fournisseur}{" · "}code article {l.codein} + +
  • + ))} +
)}
); } +// ─── Tableau des résultats ────────────────────────────────────────────────── + +type Ligne = ProduitRechercheRow & { trend: NetworkTrend }; + +const rechercherDans = (r: Ligne) => [r.libelle, r.fournisseur, r.codeCentrale, r.codein, r.nomenclature]; + function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) { - return ( -
- - - - - - - - - - - - - - - - - - {rows.map((r) => { - const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth); - const href = r.codein - ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` - : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; - return ( - - - - - - - - - - - - - - ); - })} - -
LibelléFournisseurCode centraleMagasinsQté réseauQté / mag.Prix moyenCA / mag.Marge %TendanceNous
- - {r.libelle || "—"} - - {r.nomenclature && ( -
{r.nomenclature}
- )} -
- {r.fournisseur || "—"} - - {r.codeCentrale} - - {fmtQte(r.nbMagasinsReseau)} - - {fmtDec(r.tauxPresence * 100, 0)} % - - - {fmtQte(r.qteReseau)} - - {fmtDec(r.qteParMagasinReseau)} - - {r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"} - - {r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"} - - {r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"} - - {trend.hasData ? : —} - - {r.codein ? ( - - stock {fmtQte(r.stockLocal ?? 0)} - - ) : ( - - non référencé - - )} -
-
+ // Tendance calculée une fois par ligne (et non à chaque tri ou rendu). + const lignes = useMemo( + () => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })), + [rows], ); -} + + const colonnes = useMemo[]>(() => [ + { + id: "produit", + header: "Produit", + sortValue: (r) => r.libelle, + className: "max-w-[320px]", + cell: (r) => { + const href = r.codein + ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` + : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; + return ( + <> + + {r.libelle || "—"} + + {r.nomenclature && ( +
{r.nomenclature}
+ )} + + ); + }, + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.fournisseur, + cell: (r) => {r.fournisseur || "—"}, + }, + { + id: "codeCentrale", + header: "Code centrale", + hint: GLOSSAIRE.codeCentrale.definition, + cell: (r) => {r.codeCentrale}, + }, + { + id: "magasins", + header: "Magasins vendeurs", + hint: GLOSSAIRE.presenceReseau.definition, + align: "right", + sortValue: (r) => r.nbMagasinsReseau, + cell: (r) => ( + <> + {fmtEntier(r.nbMagasinsReseau)} + ({fmtEntier(r.tauxPresence * 100)} %) + + ), + }, + { + id: "qteReseau", + header: "Quantité vendue", + hint: "Quantité vendue par l'ensemble des magasins du réseau sur les 12 derniers mois.", + align: "right", + sortValue: (r) => r.qteReseau, + cell: (r) => {fmtEntier(r.qteReseau)}, + }, + { + id: "qteParMagasin", + header: "Quantité par magasin", + hint: "Quantité moyenne vendue sur 12 mois par un magasin du réseau qui a ce produit.", + align: "right", + sortValue: (r) => r.qteParMagasinReseau, + cell: (r) => fmtDecimal1(r.qteParMagasinReseau), + }, + { + id: "prixMoyen", + header: "Prix moyen", + hint: "Prix de vente moyen constaté dans le réseau (chiffre d'affaires divisé par la quantité).", + align: "right", + sortValue: (r) => r.prixMoyenReseau, + cell: (r) => (r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"), + }, + { + id: "caParMagasin", + header: "CA par magasin", + hint: GLOSSAIRE.caParMagasin.definition, + align: "right", + sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null), + cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur0(r.caParMagasinReseau) : "—"), + }, + { + id: "marge", + header: "Taux de marge", + hint: GLOSSAIRE.marge.definition, + align: "right", + sortValue: (r) => r.margePctReseau, + cell: (r) => + r.margePctReseau != null ? ( + + {fmtDecimal1(r.margePctReseau)} % + + ) : "—", + }, + { + id: "tendance", + header: "Tendance", + hint: "Ventes par magasin des 4 derniers mois comparées aux 4 premiers mois de la période de 12 mois.", + align: "center", + sortValue: (r) => (r.trend.hasData ? r.trend.pct : null), + cell: (r) => (r.trend.hasData ? : —), + }, + { + id: "nous", + header: "Chez nous", + hint: "Notre stock (tous nos magasins) si le produit est dans notre catalogue.", + align: "right", + sortValue: (r) => (r.codein ? (r.stockLocal ?? 0) : null), + cell: (r) => + r.codein ? ( + + Stock {fmtEntier(r.stockLocal ?? 0)} + + ) : ( + Non référencé + ), + }, + ], [query]); -function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) { - // Classes littérales : Tailwind ne génère pas les classes construites dynamiquement. - const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; return ( - - {children} - + r.codeCentrale} + searchIn={rechercherDans} + searchPlaceholder="Filtrer les résultats…" + pageSize={50} + unite={lignes.length > 1 ? "produits" : "produit"} + emptyTitle="Aucun produit" + /> ); } diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx index 7d2aecd..dc55282 100644 --- a/src/app/(dashboard)/produits/search-bar.tsx +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Search, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/form-controls"; /** * Champ de recherche produit. @@ -35,43 +37,42 @@ export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) { return (
-
-
+ +
+
- setTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(); }} - placeholder="Libellé produit ou code centrale…" - aria-label="Rechercher un produit par libellé ou code centrale" - className="apple-input h-10 text-[14px]" - style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }} + placeholder="Ex. « poêle 28 » ou « 10000167303 »" + aria-describedby={tooShort ? "recherche-produit-aide" : undefined} + className="h-10 w-full pl-9 pr-9" /> {term && ( )}
- +
{tooShort && ( -

+

Saisissez au moins 3 caractères.

)} diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index 4753017..4d842d4 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -1,363 +1,424 @@ "use client"; +import { useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { useState, useMemo } from "react"; -import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { History, Info, Megaphone, RotateCcw } from "lucide-react"; import type { Publicite, PubliciteHistorique } from "./page"; +import { Badge, StoreBadge, type Ton } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; +import { Select, type SelectOption } from "@/components/ui/form-controls"; +import { Pagination } from "@/components/ui/pagination"; +import { ErrorState } from "@/components/ui/states"; +import { Tabs, useUrlTab } from "@/components/ui/tabs"; +import { Terme, InfoBulle } from "@/components/ui/tooltip"; +import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format"; +import { nomMagasin } from "@/lib/magasins"; +import { cn } from "@/lib/utils"; -function fmtDate(iso: string) { - return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); -} -function fmtCA(v: number | string) { - return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v)); -} -function fmtPct(v: number | string) { - return Number(v).toFixed(2).replace(".", ",") + " %"; -} +type Vue = "publicites" | "historique"; -type SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie" | "statut"; -type SortDir = "asc" | "desc"; - -function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) { - if (col !== sortKey) return ; - return sortDir === "asc" ? : ; -} - -const STATUT_LABELS: Record = { - en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" }, - passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" }, +const STATUTS: Record = { + en_cours: { libelle: "En cours", ton: "succes" }, + passee: { libelle: "Passée", ton: "neutre" }, // Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes. - passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" }, - a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" }, + passees: { libelle: "Passée", ton: "neutre" }, + a_venir: { libelle: "À venir", ton: "accent" }, }; +const OPTIONS_STATUT: SelectOption[] = [ + { value: "toutes", label: "Toutes" }, + { value: "en_cours", label: "En cours" }, + { value: "passees", label: "Passées" }, + { value: "a_venir", label: "À venir" }, +]; + +/** Pourquoi la liste est vide, selon le statut choisi. */ +const VIDE_PAR_STATUT: Record = { + toutes: "Aucune opération publicitaire n'est enregistrée pour le moment.", + en_cours: "Aucune opération publicitaire n'est en cours en ce moment.", + passees: "Aucune opération publicitaire passée n'a été trouvée.", + a_venir: "Aucune opération publicitaire à venir n'est encore enregistrée.", +}; + +const EXPLICATION_CA_PUB = + "Chiffre d'affaires réalisé sur les produits de l'opération pendant sa durée (du premier au dernier jour de la publicité)."; + +function fmtDate(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +/** Les montants de l'API arrivent parfois en texte : valeur absente ou illisible = 0. */ +function nombre(v: number | string | null | undefined): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function somme(rows: readonly T[], valeur: (r: T) => number | string | null | undefined): number { + return rows.reduce((s, r) => s + nombre(valeur(r)), 0); +} + +const libelleStatut = (statut?: string) => STATUTS[statut ?? ""]?.libelle ?? statut ?? ""; +/** `passee` et `passees` désignent le même statut. */ +const cleStatut = (statut?: string) => (statut === "passees" ? "passee" : statut || null); + +/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */ + function StatutBadge({ statut }: { statut?: string }) { - const s = statut ?? ""; - const cfg = STATUT_LABELS[s] ?? { label: s || "—", cls: "bg-gray-100 text-gray-500" }; + const s = STATUTS[statut ?? ""]; + if (s) return {s.libelle}; + return statut ? {statut} : —; +} + +/** Intitulé d'abord, code de l'opération en petit dessous. */ +function Operation({ intitule, code }: { intitule: string; code: string }) { return ( - - {cfg.label} +
+
{intitule || "Opération sans intitulé"}
+ {code &&
Code {code}
} +
+ ); +} + +/** Part du CA total : mise en avant à partir de 10 %, puis de 20 %. */ +function PartCa({ pct }: { pct: number }) { + return ( + = 20 ? "font-semibold text-[var(--accent)]" : pct >= 10 ? "font-semibold" : "text-[var(--text-secondary)]", + )} + > + {fmtDecimal1(pct)} % ); } +/** Pied de la colonne « Part du CA total » : somme du CA pub / somme du CA total. */ +function PartCaTotal({ rows }: { rows: readonly Publicite[] }) { + const caPub = somme(rows, (r) => r.ca_pub_periode_pub); + const caTotal = somme(rows, (r) => r.ca_total_periode_pub); + if (caTotal <= 0) { + return —; + } + return ( + + {fmtDecimal1((caPub / caTotal) * 100)} % + + ); +} + interface Props { - vue: string; + vueInitiale: Vue; statut: string; page: number; + pageSize: number; publicites: Publicite[]; total: number; pages: number; + erreurPublicites?: string; historique: PubliciteHistorique[]; + erreurHistorique?: string; } -export function PublicitesClient({ vue, statut, page, publicites, total, pages, historique }: Props) { +export function PublicitesClient({ + vueInitiale, + statut, + page, + pageSize, + publicites, + total, + pages, + erreurPublicites, + historique, + erreurHistorique, +}: Props) { const router = useRouter(); + const [chargement, startTransition] = useTransition(); + const [vue, setVue] = useUrlTab("vue", vueInitiale); - const [filterSite, setFilterSite] = useState("all"); - const [sortKey, setSortKey] = useState("intitule"); - const [sortDir, setSortDir] = useState("asc"); - - function nav(params: Record) { - const sp = new URLSearchParams({ - vue, statut, page: String(page), ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), - }); - router.push(`/publicites?${sp}`); + function naviguer(changements: { statut?: string; page?: number }) { + const sp = new URLSearchParams({ vue, statut, page: String(page) }); + if (changements.statut !== undefined) sp.set("statut", changements.statut); + if (changements.page !== undefined) sp.set("page", String(changements.page)); + startTransition(() => router.push(`/publicites?${sp}`)); } - const validPublicites = useMemo(() => publicites.filter(p => p.site !== "000"), [publicites]); + const reessayer = ( + + ); - const sites = useMemo(() => { - const s = new Set(validPublicites.map(p => p.site).filter(Boolean)); - return Array.from(s).sort(); - }, [validPublicites]); + const validPublicites = useMemo(() => publicites.filter((p) => p.site !== "000"), [publicites]); + const plusieursSites = useMemo(() => new Set(validPublicites.map((p) => p.site).filter(Boolean)).size > 1, [validPublicites]); + const paginee = pages > 1; - const SITE_COLORS: Record = useMemo(() => { - const palette = [ - "bg-blue-50 text-blue-700", "bg-violet-50 text-violet-700", - "bg-emerald-50 text-emerald-700", "bg-rose-50 text-rose-700", - "bg-amber-50 text-amber-700", "bg-cyan-50 text-cyan-700", - ]; - return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]])); - }, [sites]); + const filtresPublicites = useMemo[]>( + () => + plusieursSites + ? [{ id: "site", label: "Magasin", valueOf: (p) => p.site || null, optionLabel: (code) => nomMagasin(code), allLabel: "Tous les magasins" }] + : [], + [plusieursSites], + ); - function handleSort(key: SortKey) { - if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); - else { setSortKey(key); setSortDir("asc"); } - } + const colonnesPublicites = useMemo[]>( + () => [ + { + id: "operation", + header: "Opération", + sortValue: (p) => p.intitule, + cell: (p) => , + footer: (rows) => + `Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`, + }, + { + id: "statut", + header: "Statut", + sortValue: (p) => libelleStatut(p.statut), + cell: (p) => , + }, + { + id: "site", + header: "Magasin", + sortValue: (p) => (p.site ? nomMagasin(p.site) : null), + cell: (p) => (p.site ? : —), + }, + { + id: "debut", + header: "Début", + sortValue: (p) => p.date_debut, + cell: (p) => fmtDate(p.date_debut), + className: "whitespace-nowrap tabular-nums", + }, + { + id: "fin", + header: "Fin", + sortValue: (p) => p.date_fin, + cell: (p) => fmtDate(p.date_fin), + className: "whitespace-nowrap tabular-nums", + }, + { + id: "caPub", + header: CA pub période, + align: "right", + sortValue: (p) => nombre(p.ca_pub_periode_pub), + cell: (p) => {fmtEur0(nombre(p.ca_pub_periode_pub))}, + footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)), + }, + { + id: "partCa", + header: , + align: "right", + sortValue: (p) => nombre(p.pourc_capub_catotal), + cell: (p) => , + footer: (rows) => , + }, + { + id: "quantite", + header: "Quantité vendue", + align: "right", + sortValue: (p) => nombre(p.qte_vendue_pub), + cell: (p) => fmtEntier(nombre(p.qte_vendue_pub)), + footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_vendue_pub)), + }, + { + id: "clients", + header: "Clients", + align: "right", + sortValue: (p) => nombre(p.client_pub_periode), + cell: (p) => fmtEntier(nombre(p.client_pub_periode)), + }, + { + id: "tauxSortie", + header: , + align: "right", + sortValue: (p) => nombre(p.taux_sortie), + cell: (p) => `${fmtDecimal1(nombre(p.taux_sortie))} %`, + }, + { + id: "articles", + header: "Nb articles", + align: "right", + sortValue: (p) => nombre(p.nb_articles), + cell: (p) => fmtEntier(nombre(p.nb_articles)), + }, + ], + [paginee], + ); - const filtered = useMemo(() => { - let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite); - return [...rows].sort((a, b) => { - const va = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut") - ? String(a[sortKey] ?? "").toLowerCase() - : Number(a[sortKey as keyof Publicite]); - const vb = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut") - ? String(b[sortKey] ?? "").toLowerCase() - : Number(b[sortKey as keyof Publicite]); - if (va < vb) return sortDir === "asc" ? -1 : 1; - if (va > vb) return sortDir === "asc" ? 1 : -1; - return 0; - }); - }, [validPublicites, filterSite, sortKey, sortDir]); + const filtresHistorique = useMemo[]>( + () => [{ id: "statut", label: "Statut", valueOf: (h) => cleStatut(h.statut), optionLabel: (s) => libelleStatut(s), allLabel: "Tous" }], + [], + ); - function thProps(key: SortKey, cls = "") { - return { className: `${cls} cursor-pointer select-none hover:bg-gray-100 transition-colors`, onClick: () => handleSort(key) }; - } + const colonnesHistorique = useMemo[]>( + () => [ + { + id: "operation", + header: "Opération", + sortValue: (h) => h.intitule, + cell: (h) => , + footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`, + }, + { + id: "statut", + header: "Statut", + sortValue: (h) => libelleStatut(h.statut), + cell: (h) => , + }, + { + id: "debut", + header: "Début", + sortValue: (h) => h.date_debut, + cell: (h) => fmtDate(h.date_debut), + className: "whitespace-nowrap tabular-nums", + }, + { + id: "fin", + header: "Fin", + sortValue: (h) => h.date_fin, + cell: (h) => fmtDate(h.date_fin), + className: "whitespace-nowrap tabular-nums", + }, + { + id: "magasins", + header: "Magasins", + hint: "Nombre de magasins concernés par l'opération.", + align: "right", + sortValue: (h) => nombre(h.nb_sites), + cell: (h) => fmtEntier(nombre(h.nb_sites)), + }, + { + id: "ca", + header: "CA total", + hint: "Chiffre d'affaires de l'opération, tous magasins confondus.", + align: "right", + sortValue: (h) => nombre(h.ca_total), + cell: (h) => {fmtEur0(nombre(h.ca_total))}, + footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_total)), + }, + { + id: "quantite", + header: "Quantité vendue", + align: "right", + sortValue: (h) => nombre(h.qte_totale), + cell: (h) => fmtEntier(nombre(h.qte_totale)), + footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_totale)), + }, + ], + [], + ); - const isHistorique = vue === "historique"; + const onglets = [ + { value: "publicites", label: "Publicités", icon: Megaphone, count: erreurPublicites ? undefined : total }, + { value: "historique", label: "Historique", icon: History, count: erreurHistorique ? undefined : historique.length }, + ] as const; + + const selectStatut = ( + nav({ vue, statut: e.target.value, page: 1 })} - className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[160px]" - > - - - - - -
- - {sites.length > 1 && ( -
- - -
- )} - -
- - - {total} publicité{total !== 1 ? "s" : ""} au total - - - )} - - {isHistorique && ( - - {historique.length} opération{historique.length !== 1 ? "s" : ""} - - )} -
- - {/* ── Vue Publicités ── */} - {!isHistorique && ( - <> -
- {filtered.length === 0 ? ( -
Aucune publicité trouvée.
- ) : ( - - - - - - - - - - - - - - - - - - - {filtered.map((pub, idx) => ( - - - - - - - - - - - - - - - ))} - - - - - - - - - -
N° - Opération - - Statut - - Magasin - Date débutDate fin - CA pub période - - % CA total - - Qté vendue - Clients - Taux sortie - Nb articles
{pub.tcr_code}{pub.intitule} - - {pub.site || "—"} - - {fmtDate(pub.date_debut)}{fmtDate(pub.date_fin)}{fmtCA(pub.ca_pub_periode_pub)} - = 20 ? "bg-orange-100 text-orange-700" - : Number(pub.pourc_capub_catotal) >= 10 ? "bg-amber-100 text-amber-700" - : "bg-gray-100 text-gray-600" - }`}> - {fmtPct(pub.pourc_capub_catotal)} - - {Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}{Number(pub.client_pub_periode).toLocaleString("fr-FR")}{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %{pub.nb_articles}
- TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages}) - - {fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))} - — - {filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")} - -
- )} -
- - {/* ── Pagination ── */} - {pages > 1 && ( -
- - Page {page} sur {pages} — {total} publicités - -
- - {/* Pages around current */} - {Array.from({ length: Math.min(pages, 7) }, (_, i) => { - const p = pages <= 7 ? i + 1 : Math.max(1, page - 3) + i; - if (p > pages) return null; - return ( - - ); - })} - -
-
- )} - - )} - - {/* ── Vue Historique ── */} - {isHistorique && ( -
- {historique.length === 0 ? ( -
Aucun historique disponible.
+
+ {vue === "publicites" ? ( + erreurPublicites ? ( + ) : ( - - - - - - - - - - - - - - - {historique.map((h, idx) => ( - - - - - - - - - - - ))} - - - - - - - - -
N°OpérationStatutDate débutDate finSitesCA totalQté totale
{h.tcr_code}{h.intitule}{fmtDate(h.date_debut)}{fmtDate(h.date_fin)}{h.nb_sites}{fmtCA(h.ca_total)}{Number(h.qte_totale).toLocaleString("fr-FR")}
- TOTAL — {historique.length} opérations - - {fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))} - - {historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")} -
- )} -
- )} +
+ {paginee && ( +

+ + + + Tri et filtre magasin appliqués à cette page. + {" "} + Les publicités sont chargées par pages de {fmtEntier(pageSize)} (page {fmtEntier(page)} sur{" "} + {fmtEntier(pages)}) : la ligne Total ne porte, elle aussi, que sur la page affichée. + +

+ )} + `${p.tcr_code}-${p.site}-${i}`} + filters={filtresPublicites} + toolbar={selectStatut} + pageSize={0} + unite="publicités" + showFooter + initialSort={{ id: "operation", dir: "asc" }} + emptyTitle="Aucune publicité à afficher" + emptyDescription={descriptionVide} + /> + naviguer({ page: p })} + unite="publicités" + /> +
+ ) + ) : erreurHistorique ? ( + + ) : ( + `${h.tcr_code}-${i}`} + searchIn={(h) => [h.intitule, h.tcr_code]} + searchPlaceholder="Rechercher une opération ou un code…" + filters={filtresHistorique} + unite="opérations" + showFooter + emptyTitle="Aucune opération dans l'historique" + emptyDescription="L'historique des opérations publicitaires est vide pour le moment." + /> + )} +
); } diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 818fb37..259ea49 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -1,6 +1,11 @@ +import type { Metadata } from "next"; +import { Megaphone } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; import { PublicitesClient } from "./client"; import { cachedFF } from "@/lib/ff-cache"; +export const metadata: Metadata = { title: "Publicités" }; + export interface Publicite { tcr_code: string; intitule: string; @@ -114,27 +119,25 @@ export default async function PublicitesPage(props: { fetchHistorique(), ]); - const error = pubResult.error ?? histoResult.error; - return ( -
-
-

Publicités

- {error && ( -
- {error} -
- )} - -
+
+ +
); } diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx new file mode 100644 index 0000000..dca2313 --- /dev/null +++ b/src/app/(dashboard)/settings/client.tsx @@ -0,0 +1,619 @@ +"use client"; + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { + AlertCircle, + CheckCircle2, + Eye, + EyeOff, + FileText, + KeyRound, + Loader2, + PlugZap, + RefreshCw, + RotateCcw, + Save, + Settings, + Users, +} from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { Tabs, useUrlTab, type TabItem } from "@/components/ui/tabs"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input, Label } from "@/components/ui/form-controls"; +import { Terme } from "@/components/ui/tooltip"; +import { toast } from "@/components/ui/feedback"; +import { fmtEntier } from "@/lib/format"; +import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; +import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store"; +import { + testDatabaseConnection, + saveDatabaseSettings, + getSavedDatabaseConfig, + saveQlikSettings, + testQlikConnection, + saveFfApiSettings, + testFfApiConnection, +} from "@/features/settings/actions"; +import { UserManagement } from "@/features/admin/components/user-management"; +import { ApiKeyManagement } from "@/features/admin/components/api-key-management"; +import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info"; +import { GridWarmup } from "@/features/admin/components/grid-warmup"; +import { ServerLogs } from "@/features/settings/components/server-logs"; + +/** + * Configuration enregistrée, lue UNE fois pour toute la page : trois sections + * (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions + * serveur s'exécutent les unes après les autres. + */ +let savedConfigPromise: ReturnType | null = null; +function loadSavedConfig(force = false): ReturnType { + if (force || !savedConfigPromise) { + savedConfigPromise = getSavedDatabaseConfig().catch((e) => { + savedConfigPromise = null; + throw e; + }); + } + return savedConfigPromise; +} + +/** Message lisible d'une erreur de server action (onglet périmé compris). */ +function messageErreur(e: unknown): string { + if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE; + return e instanceof Error ? e.message : String(e); +} + +function fmtDateHeure(valeur: string | null | undefined): string { + if (!valeur) return "—"; + const d = new Date(valeur); + return isNaN(d.getTime()) ? valeur : d.toLocaleString("fr-FR"); +} + +// ── Onglets ────────────────────────────────────────────────────────────────── + +type Onglet = "connexions" | "utilisateurs" | "api" | "journal"; + +const ONGLETS: readonly TabItem[] = [ + { value: "connexions", label: "Connexions", icon: PlugZap }, + { value: "utilisateurs", label: "Utilisateurs", icon: Users }, + { value: "api", label: "Accès API", icon: KeyRound }, + { value: "journal", label: "Journal serveur", icon: FileText }, +]; + +function estOnglet(v: string | undefined): v is Onglet { + return ONGLETS.some((o) => o.value === v); +} + +/** + * Contenu d'un onglet : monté à la première visite puis seulement masqué, pour + * qu'une saisie non enregistrée survive au changement d'onglet et que rien ne + * soit rechargé en revenant. + */ +function Panneau({ actif, visite, children }: { actif: boolean; visite: boolean; children: ReactNode }) { + if (!visite) return null; + return ( + + ); +} + +// ── Éléments de formulaire ─────────────────────────────────────────────────── + +function Champ({ id, label, aide, children, className }: { + id: string; + label: ReactNode; + aide?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ + {children} + {aide &&

{aide}

} +
+ ); +} + +type EtatTest = { etat: "idle" } | { etat: "en-cours" } | { etat: "ok" } | { etat: "erreur"; message: string }; + +/** Résultat d'un test de connexion, affiché à côté des boutons. */ +function ResultatTest({ test }: { test: EtatTest }) { + if (test.etat === "ok") { + return ( + + Connexion réussie + + ); + } + if (test.etat === "erreur") { + return ( +

+ + + Connexion impossible.{" "} + {test.message} + +

+ ); + } + return null; +} + +// ── PostgreSQL ─────────────────────────────────────────────────────────────── + +function SectionPostgres({ recharger }: { recharger: () => Promise }) { + const { + host, setHost, + port, setPort, + database, setDatabase, + user, setUser, + password, setPassword, + ssl, setSsl, + getDatabaseUrl, + } = useDbSettingsStore(); + + const [test, setTest] = useState({ etat: "idle" }); + const [enregistrement, setEnregistrement] = useState(false); + const [rechargement, setRechargement] = useState(false); + + const tester = async () => { + setTest({ etat: "en-cours" }); + try { + const res = await testDatabaseConnection(getDatabaseUrl()); + setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." }); + } catch (e) { + setTest({ etat: "erreur", message: messageErreur(e) }); + } + }; + + const enregistrer = async () => { + setEnregistrement(true); + try { + const res = await saveDatabaseSettings(getDatabaseUrl()); + if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée."); + else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`); + } catch (e) { + toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); + } finally { + setEnregistrement(false); + } + }; + + const rechargerConfig = async () => { + setRechargement(true); + try { + await recharger(); + toast.info("Les valeurs enregistrées sur le serveur ont été rechargées."); + } catch (e) { + toast.erreur(`La configuration n'a pas pu être relue : ${messageErreur(e)}`); + } finally { + setRechargement(false); + } + }; + + return ( + + + +
+ + setHost(e.target.value)} /> + + + setPort(e.target.value)} /> + + + setDatabase(e.target.value)} /> + + + setUser(e.target.value)} autoComplete="off" /> + + + setPassword(e.target.value)} autoComplete="new-password" /> + +
+ +
+ +

+ À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance. +

+
+ +
+ + + + +
+
+
+ ); +} + +// ── API FF Nancy ───────────────────────────────────────────────────────────── + +interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; } +interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; } + +function SectionFfApi() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [url, setUrl] = useState(""); + const [savedUrl, setSavedUrl] = useState(null); + const [saving, setSaving] = useState(false); + const [testedUrl, setTestedUrl] = useState(null); + + // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). + useEffect(() => { + loadSavedConfig() + .then((cfg) => { + setSavedUrl(cfg?.ffApiBaseUrl ?? null); + if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); + }) + .catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ }); + }, []); + + const runTest = useCallback((candidate?: string) => { + setLoading(true); + setError(null); + setStatus(null); + testFfApiConnection(candidate) + .then((res) => { + setTestedUrl(res.url); + if (res.success) setStatus(res.status as FfSyncStatus); + else setError(res.error ?? "Échec inconnu."); + }) + .catch((e) => setError(messageErreur(e))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { runTest(); }, [runTest]); + + const save = async () => { + setSaving(true); + try { + const res = await saveFfApiSettings(url); + if (!res.success) { + toast.erreur(res.error ?? "Enregistrement impossible."); + return; + } + setSavedUrl(url.trim() || null); + toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée."); + runTest(url); + } catch (e) { + toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); + } finally { + setSaving(false); + } + }; + + const test: EtatTest = loading ? { etat: "en-cours" } : status ? { etat: "ok" } : error ? { etat: "erreur", message: error } : { etat: "idle" }; + + return ( + + + + + setUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void save(); }} + className="w-full font-mono" + /> + + +
+ + + +
+ + {/* L'URL réellement appelée : évite de croire qu'on teste celle du champ + alors que le réglage enregistré ou la variable d'environnement prime. */} + {testedUrl && ( +

+ Adresse testée : {testedUrl} + {!savedUrl && <> (adresse par défaut, aucun réglage enregistré)} +

+ )} + + {status && ( +
+

+ Dernière mise à jour complète des données :{" "} + {fmtDateHeure(status.lastSync)} +

+ {status.tables?.length > 0 && ( +
+ + + + + + + + + + + {status.tables.map((t) => ( + + + + + {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} + + + ))} + +
Table de donnéesMise à jourLignesÉtat
{t.nom}{fmtDateHeure(t.derniereSync)} + {t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"} + + {t.statut === "ok" + ? OK + : {t.statut ?? "—"}} +
+
+ )} +
+ )} +
+
+ ); +} + +// ── Qlik Sense ─────────────────────────────────────────────────────────────── + +function SectionQlik() { + const [host, setHost] = useState(""); + const [user, setUser] = useState(""); + const [password, setPassword] = useState(""); + const [showPwd, setShowPwd] = useState(false); + const [saving, setSaving] = useState(false); + const [test, setTest] = useState({ etat: "idle" }); + + useEffect(() => { + loadSavedConfig() + .then((c) => { + if (!c) return; + setHost(c.qlikHost ?? ""); + setUser(c.qlikUser ?? ""); + setPassword(c.qlikPassword ?? ""); + }) + .catch(() => { /* l'échec de lecture est signalé par la page */ }); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await saveQlikSettings(host.trim(), user.trim(), password); + if (res.success) toast.succes("Identifiants Qlik enregistrés."); + else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`); + } catch (e) { + toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTest({ etat: "en-cours" }); + try { + const res = await testQlikConnection(host.trim(), user.trim(), password); + setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." }); + } catch (e) { + setTest({ etat: "erreur", message: messageErreur(e) }); + } + }; + + const testImpossible = !user || !password; + + return ( + + + Identifiants utilisés pour récupérer les ventes des magasins du{" "} + réseau dans l'outil de reporting Qlik Sense de La Foir'Fouille. + + } + /> + + + setHost(e.target.value)} className="w-full font-mono" /> + +
+ + setUser(e.target.value)} className="w-full font-mono" autoComplete="off" /> + + +
+ setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" /> + +
+
+
+ +
+ + + +
+
+
+ ); +} + +// ── Page ───────────────────────────────────────────────────────────────────── + +export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) { + const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions"; + const [onglet, setOnglet] = useUrlTab("onglet", initial); + const [visites, setVisites] = useState>(() => new Set([initial])); + const [isMounted, setIsMounted] = useState(false); + + const setHost = useDbSettingsStore((s) => s.setHost); + const setPort = useDbSettingsStore((s) => s.setPort); + const setDatabase = useDbSettingsStore((s) => s.setDatabase); + const setUser = useDbSettingsStore((s) => s.setUser); + const setPassword = useDbSettingsStore((s) => s.setPassword); + const setSsl = useDbSettingsStore((s) => s.setSsl); + + const reloadFromServer = useCallback(async (force = false) => { + const config = await loadSavedConfig(force); + if (config?.url) { + // Parser l'URL pour remettre dans le store + try { + const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper + setHost(url.hostname); + setPort(url.port || "5432"); + setDatabase(url.pathname.slice(1).split("?")[0]); + setUser(url.username); + setPassword(decodeURIComponent(url.password)); + setSsl(config.url.includes("sslmode=require")); + } catch (e) { + console.error("Failed to parse saved URL", e); + } + } + }, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]); + + useEffect(() => { + // `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL + // lus dans le localStorage) : 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); + // Lecture fraîche à chaque ouverture de la page ; les sections, montées + // juste après (cf. `isMounted`), réutilisent cette même requête. + reloadFromServer(true).catch((e) => { + toast.erreur(`La configuration enregistrée n'a pas pu être lue : ${messageErreur(e)}`); + }); + }, [reloadFromServer]); + + const changerOnglet = (suivant: Onglet) => { + setOnglet(suivant); + setVisites((prev) => (prev.has(suivant) ? prev : new Set([...prev, suivant]))); + }; + + const recharger = useCallback(() => reloadFromServer(true), [reloadFromServer]); + + return ( +
+ + + + + {isMounted && ( + <> + + + + + + + + + + + + + + + + + + + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 73544e4..e0fbfa6 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,544 +1,16 @@ -"use client"; +import type { Metadata } from "next"; +import { ParametresClient } from "./client"; -import { useState, useCallback } from "react"; -import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, RotateCcw } from "lucide-react"; -import { useTheme } from "next-themes"; -import { useGridStore } from "@/features/grid/store/use-grid-store"; -import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store"; -import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions"; -import { useEffect } from "react"; -import { UserManagement } from "@/features/admin/components/user-management"; -import { ApiKeyManagement } from "@/features/admin/components/api-key-management"; -import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info"; -import { GridWarmup } from "@/features/admin/components/grid-warmup"; -import { ServerLogs } from "@/features/settings/components/server-logs"; +export const metadata: Metadata = { title: "Paramètres" }; /** - * Configuration enregistrée, lue UNE fois pour toute la page : trois sections - * (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions - * serveur s'exécutent les unes après les autres. + * Paramètres (administrateurs) : connexions aux sources de données, comptes + * utilisateurs, accès à l'API et journal serveur, rangés en onglets. + * + * Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le + * lien se partage et survive au rechargement, puis rend la page cliente. */ -let savedConfigPromise: ReturnType | null = null; -function loadSavedConfig(force = false): ReturnType { - if (force || !savedConfigPromise) { - savedConfigPromise = getSavedDatabaseConfig().catch((e) => { - savedConfigPromise = null; - throw e; - }); - } - return savedConfigPromise; -} - -function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
- {children} -
-
- ); -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} - -interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; } -interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; } - -function FfApiStatusSection() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [url, setUrl] = useState(""); - const [savedUrl, setSavedUrl] = useState(null); - const [saving, setSaving] = useState(false); - const [testedUrl, setTestedUrl] = useState(null); - - // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). - useEffect(() => { - loadSavedConfig() - .then((cfg) => { - setSavedUrl(cfg?.ffApiBaseUrl ?? null); - if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); - }) - .catch(() => { /* réglage optionnel : on laisse le champ vide */ }); - }, []); - - const runTest = useCallback((candidate?: string) => { - setLoading(true); - setError(null); - setStatus(null); - testFfApiConnection(candidate) - .then((res) => { - setTestedUrl(res.url); - if (res.success) setStatus(res.status as FfSyncStatus); - else setError(res.error ?? "Échec inconnu"); - }) - .catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue")) - .finally(() => setLoading(false)); - }, []); - - useEffect(() => { runTest(); }, [runTest]); - - const save = async () => { - setSaving(true); - setError(null); - try { - const res = await saveFfApiSettings(url); - if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; } - setSavedUrl(url.trim() || null); - runTest(url); - } finally { - setSaving(false); - } - }; - - return ( -
- - setUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") void save(); }} - className="apple-input w-full" - /> - - -
- - - {status && Connexion OK} - {error && {error}} -
- - {/* L'URL réellement appelée : évite de croire qu'on teste celle du champ - alors que le réglage enregistré ou la variable d'environnement prime. */} - {testedUrl && ( -

- Adresse testée : {testedUrl} - {!savedUrl && <> — valeur par défaut, aucun réglage enregistré} -

- )} - - {status && ( -
-

- Dernière sync globale : {(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()} -

- {status.tables?.length > 0 && ( -
- - - - - - - - - - - {status.tables.map((t) => ( - - - - - {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} - - - ))} - -
TableSyncLignesÉtat
{t.nom} - {(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()} - - {t.nbLignes?.toLocaleString("fr-FR") ?? "—"} - - {t.statut === "ok" - ? ok - : {t.statut ?? "—"}} -
-
- )} -
- )} -
- ); -} - -function QlikSettingsSection() { - const [host, setHost] = useState(""); - const [user, setUser] = useState(""); - const [password, setPassword] = useState(""); - const [showPwd, setShowPwd] = useState(false); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null); - - useEffect(() => { - loadSavedConfig().then((c) => { - if (!c) return; - setHost(c.qlikHost ?? ""); - setUser(c.qlikUser ?? ""); - setPassword(c.qlikPassword ?? ""); - }); - }, []); - - const handleSave = async () => { - setSaving(true); - setStatus(null); - const res = await saveQlikSettings(host.trim(), user.trim(), password); - setSaving(false); - setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" }); - }; - - const handleTest = async () => { - setTesting(true); - setStatus(null); - const res = await testQlikConnection(host.trim(), user.trim(), password); - setTesting(false); - setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" }); - }; - - return ( -
- - setHost(e.target.value)} className="apple-input font-mono" /> - - - setUser(e.target.value)} className="apple-input font-mono" /> - - -
- setPassword(e.target.value)} className="apple-input font-mono pr-10" /> - -
-
- {status && ( -
- {status.type === "ok" ? : } - {status.msg} -
- )} -
- - -
-
- ); -} - -export default function SettingsPage() { - const { resolvedTheme, setTheme } = useTheme(); - const isDark = resolvedTheme === "dark"; - const displayDensity = useGridStore((s) => s.displayDensity); - const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); - - const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle"); - const [dbError, setDbError] = useState(null); - const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); - - const [isMounted, setIsMounted] = useState(false); - - const { - host, setHost, - port, setPort, - database, setDatabase, - user, setUser, - password, setPassword, - ssl, setSsl, - getDatabaseUrl - } = useDbSettingsStore(); - - const reloadFromServer = useCallback(async (force = false) => { - const config = await loadSavedConfig(force); - if (config) { - if (config.url) { - // Parser l'URL pour remettre dans le store - try { - const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper - setHost(url.hostname); - setPort(url.port || "5432"); - setDatabase(url.pathname.slice(1).split("?")[0]); - setUser(url.username); - setPassword(decodeURIComponent(url.password)); - setSsl(config.url.includes("sslmode=require")); - } catch (e) { - console.error("Failed to parse saved URL", e); - } - } - } - }, [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); - // Lecture fraîche à chaque ouverture de la page ; les sections, montées - // juste après (cf. `isMounted`), réutilisent cette même requête. - reloadFromServer(true); - }, [reloadFromServer]); - - const testDb = async () => { - setDbStatus("testing"); - setDbError(null); - const url = getDatabaseUrl(); - const res = await testDatabaseConnection(url); - if (res.success) { - setDbStatus("ok"); - } else { - setDbStatus("error"); - setDbError(res.error || "Erreur de connexion inconnue"); - } - }; - - const handleSave = async () => { - setSaveStatus("saving"); - const url = getDatabaseUrl(); - const res = await saveDatabaseSettings(url); - if (res.success) { - setSaveStatus("saved"); - setTimeout(() => setSaveStatus("idle"), 2500); - } else { - setSaveStatus("idle"); - alert("Erreur lors de la sauvegarde : " + res.error); - } - }; - - if (!isMounted) { - return null; - } - - return ( -
-
-

Paramètres

-

Configuration de la connexion et des services.

-
- - {/* PostgreSQL */} -
- - setHost(e.target.value)} - className="apple-input font-mono" - /> - -
- - setPort(e.target.value)} - className="apple-input font-mono" - /> - - - setDatabase(e.target.value)} - className="apple-input font-mono" - /> - -
-
- - setUser(e.target.value)} - className="apple-input font-mono" - /> - - - setPassword(e.target.value)} - className="apple-input font-mono" - /> - -
-
- setSsl(e.target.checked)} - className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" - /> - -
-
- - {dbStatus === "error" && ( -
- Connexion refusée - {dbError && {dbError}} -
- )} - {dbStatus === "ok" && ( - Connexion réussie ! - )} -
-
- -
-
- - {/* API FF Nancy */} - - - - - {/* Apparence */} -
-
-
-

Thème

-

Interface claire ou sombre

-
- {/* Segment control */} -
- - -
-
- -
-
-

Densité de la grille

-

Hauteur de ligne par défaut

-
- -
-
- - {/* Journaux serveur — diagnostic des extractions Qlik */} -
- -
- - {/* Gestion des Utilisateurs */} -
- -
- - {/* API publique /api/v1 — connexion puis gestion des clés */} -
- -
- -
- -
- -
- -
- - {/* Save Button — Floating/Sticky style at bottom */} -
- -
-
- ); +export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) { + const { onglet } = await props.searchParams; + return ; } diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index e8a5082..995d440 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -2,28 +2,20 @@ import { useRouter } from "next/navigation"; 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 { Loader2 } from "lucide-react"; +import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; +import { Tabs, type TabItem } from "@/components/ui/tabs"; +import { Select } from "@/components/ui/form-controls"; +import { StoreBadge } from "@/components/ui/badge"; +import { Terme } from "@/components/ui/tooltip"; +import { MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier } from "@/lib/format"; +import { cn } from "@/lib/utils"; 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); @@ -40,7 +32,7 @@ function getYear(raw: string | null): string { /** * Passe à `true` seulement si `active` reste vrai plus de `delay` ms. - * Évite le clignotement du modal sur les transitions instantanées. + * Évite le clignotement de l'indicateur sur les transitions instantanées. */ function useDelayedFlag(active: boolean, delay = 120) { const [elapsed, setElapsed] = useState(false); @@ -63,17 +55,15 @@ function useDelayedFlag(active: boolean, delay = 120) { } // --------------------------------------------------------------------------- -// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export) +// Export Excel — format d'import de régularisation dans FF. +// +// En-têtes (CODEIN / GENCODE / QTE / CODMV), codes mouvement (503 / 412), +// colonnes, valeurs et nom de fichier : à ne pas modifier, le fichier est +// importé tel quel dans FF. (`telechargerExcel` n'est pas utilisé : il fige la +// ligne d'en-tête et renseigne les propriétés du classeur, le fichier ne +// serait donc plus strictement identique.) // --------------------------------------------------------------------------- -interface Column { - key: Extract; - label: string; - /** Tri numérique au lieu du tri alphabétique. */ - numeric?: boolean; - render?: (row: T) => ReactNode; -} - interface ExcelSpec { sheet: string; filePrefix: string; @@ -82,432 +72,243 @@ interface ExcelSpec { row: (r: T) => (string | number)[]; } -interface StockTableProps { - rows: T[]; - magasin: string; - columns: Column[]; - 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; +async function exporterPourFF(excel: ExcelSpec, rows: readonly T[], magasin: string) { + 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 rows) 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); } -function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) { - if (!active) return ; - return dir === "asc" - ? - : ; -} +const EXCEL_NEGATIF: ExcelSpec = { + 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)], +}; -function FilterSelect({ label, value, onChange, options }: { - label: string; - value: string; - onChange: (v: string) => void; - options: string[]; -}) { - return ( -
- - -
- ); -} +const EXCEL_SANS_VENTE: ExcelSpec = { + 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)], +}; -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; - - 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 ( -
- - Lignes {from.toLocaleString("fr-FR")} - {" à "} - {to.toLocaleString("fr-FR")} - {" sur "} - {total.toLocaleString("fr-FR")} - -
- - - - Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")} - - - -
-
- ); -} - -function StockTable({ - rows, - magasin, - columns, - fournisseurOf, - anneeOf, - anneeLabel = "Année entrée", - searchIn, - emptyLabel, - excel, -}: StockTableProps) { - 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 | null>(null); - const [sortDir, setSortDir] = useState("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)[sortKey]; - const bv = (b as Record)[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) { - if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc")); - else { setSortKey(key); setSortDir("asc"); } - } - - 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); - } - } - - return ( -
- {exporting && ( - - )} - -
-
-
- - 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]" - /> -
- - {anneeOf && ( - - )} - {hasFilters && ( - - )} -
-
- - {total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""} - {hasFilters && total !== rows.length && ( - / {rows.length.toLocaleString("fr-FR")} - )} - - -
-
- -
- - - - {columns.map(c => ( - - ))} - - - - {pageRows.length === 0 && ( - - - - )} - {pageRows.map((row, i) => ( - ).codein)}-${String((row as Record).site)}-${i}`} - className="hover:bg-gray-50 transition-colors"> - {columns.map(c => ( - - ))} - - ))} - -
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} - -
{emptyLabel}
- {c.render - ? c.render(row) - : String((row as Record)[c.key] ?? "")} -
-
- - setPage(Math.min(Math.max(1, p), totalPages))} - /> -
- ); -} +const EXCEL_SANS_VENTE_6MOIS: ExcelSpec = { + 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)], +}; // --------------------------------------------------------------------------- -// Cellules partagées -// --------------------------------------------------------------------------- - -const cellCode = (v: string) => {v}; -const cellSite = (v: string) => ( - {v} -); -const cellDate = (v: string | null) => {fmtDate(v)}; - -// --------------------------------------------------------------------------- -// Configuration des 3 onglets +// Colonnes et filtres 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. +// Définis au niveau module : leur identité doit rester stable d'un rendu à +// l'autre, sinon les `useMemo` du tableau se recalculent en boucle. // --------------------------------------------------------------------------- -type TabConfig = Omit, "rows" | "magasin">; +interface LigneStock { + codein: string; + libelle1: string; + fournisseur: string; + site: string; +} -const NEGATIF_CONFIG: TabConfig = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stockdispo", label: "Stock", numeric: true, render: r => {r.stockdispo} }, - { 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 SANS_VENTE_CONFIG: TabConfig = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, - { 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 SANS_VENTE_6MOIS_CONFIG: TabConfig = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, - { key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) }, +/** Colonnes communes aux 3 onglets : code, libellé, fournisseur, magasin. */ +function colonnesIdentite(): DataColumn[] { + return [ { - key: "jours_sans_vente", - label: "Jours sans vente", - numeric: true, - render: r => ( - 365 ? "#ef4444" : "#6b7280" }} - > - {r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"} - - ), + id: "codein", + header: "Code article", + sortValue: r => r.codein, + cell: r => {r.codein}, + className: "whitespace-nowrap", }, - { 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)], + { + id: "libelle1", + header: "Libellé", + sortValue: r => r.libelle1, + cell: r => r.libelle1, + className: "min-w-[220px]", + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: r => r.fournisseur, + cell: r => {r.fournisseur}, + }, + { + id: "site", + header: "Magasin", + sortValue: r => nomMagasin(r.site), + cell: r => , + }, + ]; +} + +/** Quantité en stock : rouge si négative, en clair sinon. */ +function celluleStock(v: number) { + return ( + + {fmtDecimal1(v)} + + ); +} + +const celluleDate = (v: string | null) => ( + {fmtDate(v)} +); + +const rechercheStock = (r: LigneStock) => [r.codein, r.libelle1]; + +function filtreFournisseur(): DataFilter { + return { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" }; +} + +function filtreAnneeEntree(anneeOf: (r: T) => string): DataFilter { + return { id: "annee", label: "Année d'entrée", valueOf: anneeOf, allLabel: "Toutes les années", order: "desc" }; +} + +const COLONNES_NEGATIF: DataColumn[] = [ + ...colonnesIdentite(), + { + id: "stockdispo", + header: "Stock", + hint: "Stock calculé dans FF : il est négatif quand des ventes ont été enregistrées sans l'entrée de marchandise correspondante.", + align: "right", + sortValue: r => r.stockdispo, + cell: r => celluleStock(r.stockdispo), }, -}; + { id: "dernierevente", header: "Dernière vente", sortValue: r => r.dernierevente, cell: r => celluleDate(r.dernierevente) }, + { id: "derniereentree", header: "Dernière entrée", sortValue: r => r.derniereentree, cell: r => celluleDate(r.derniereentree) }, +]; +const FILTRES_NEGATIF: DataFilter[] = [ + filtreFournisseur(), + filtreAnneeEntree(r => getYear(r.derniereentree)), +]; + +const COLONNES_SANS_VENTE: DataColumn[] = [ + ...colonnesIdentite(), + { + id: "stock_actuel", + header: "Stock actuel", + align: "right", + sortValue: r => r.stock_actuel, + cell: r => celluleStock(r.stock_actuel), + }, + { id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) }, +]; +const FILTRES_SANS_VENTE: DataFilter[] = [ + filtreFournisseur(), + filtreAnneeEntree(r => getYear(r.derniere_entree)), +]; + +const COLONNES_SANS_VENTE_6MOIS: DataColumn[] = [ + ...colonnesIdentite(), + { + id: "stock_actuel", + header: "Stock actuel", + align: "right", + sortValue: r => r.stock_actuel, + cell: r => celluleStock(r.stock_actuel), + }, + { id: "derniere_vente", header: "Dernière vente", sortValue: r => r.derniere_vente, cell: r => celluleDate(r.derniere_vente) }, + { + id: "jours_sans_vente", + header: "Jours sans vente", + hint: "Nombre de jours depuis la dernière vente. En rouge : plus d'un an.", + align: "right", + sortValue: r => r.jours_sans_vente, + cell: r => + r.jours_sans_vente != null ? ( + 365 ? "text-[var(--accent-error)]" : "text-[var(--text-secondary)]", + )} + > + {fmtEntier(r.jours_sans_vente)} + + ) : ( + Jamais vendu + ), + }, + { id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) }, +]; +const FILTRES_SANS_VENTE_6MOIS: DataFilter[] = [filtreFournisseur()]; + +const cleLigne = (r: LigneStock, i: number) => `${r.codein}-${r.site}-${i}`; // --------------------------------------------------------------------------- // Composant principal avec onglets // --------------------------------------------------------------------------- -const TABS = [ - { key: "negatif", label: "Stock négatif" }, - { key: "sans-vente", label: "Entrées sans vente" }, - { key: "sans-vente-6mois", label: "Sans vente 6 mois" }, -] as const; +const TAB_KEYS = ["negatif", "sans-vente", "sans-vente-6mois"] as const; -type TabKey = typeof TABS[number]["key"]; +type TabKey = typeof TAB_KEYS[number]; function isTabKey(v: string): v is TabKey { - return TABS.some(t => t.key === v); + return (TAB_KEYS as readonly string[]).includes(v); } +/** Ce que liste chaque onglet, et quoi en faire. */ +const EXPLICATIONS: Record = { + "negatif": ( + <> + Produits dont le stock est négatif : des ventes ont été enregistrées sans + l'entrée de marchandise correspondante. Vérifiez s'il manque une réception, puis exportez la + liste : le fichier Excel est prêt à être importé dans FF pour régulariser le stock. + + ), + "sans-vente": ( + <> + Produits reçus avant ce mois-ci, encore en stock, mais qui n'ont jamais été vendus dans ce magasin. + Vérifiez qu'ils sont bien en rayon et étiquetés ; si le stock est erroné, exportez la liste : le + fichier Excel est prêt à être importé dans FF pour le régulariser. + + ), + "sans-vente-6mois": ( + <> + Produits en stock qui ne se sont pas vendus depuis plus de 6 mois (ou jamais) et qui n'ont pas été + réapprovisionnés depuis. Mettez-les en avant ou en promotion ; si le stock est erroné, le fichier Excel + exporté est prêt à être importé dans FF pour le régulariser. + + ), +}; + +const OPTIONS_MAGASINS = MAGASINS.map(m => ({ value: m.code, label: nomMagasin(m.code) })); + interface Props { rowsNegatif: PgStockNegatifRow[]; rowsSansVente: PgStockSansVenteRow[]; rowsSansVente6Mois: PgSansVente6MoisRow[]; magasin: string; tab: string; - sites: { code: string; label: string }[]; } -export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) { +export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab }: Props) { const router = useRouter(); const initialTab: TabKey = isTabKey(tab) ? tab : "negatif"; @@ -531,8 +332,7 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M return `/stock-negatif?${params.toString()}`; } - function handleMagasinChange(e: React.ChangeEvent) { - const val = e.target.value; + function handleMagasinChange(val: string) { startNavigation(() => { router.push(buildUrl(activeTab, val)); }); @@ -545,83 +345,87 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M startSwitch(() => setActiveTab(key)); } - const counts: Record = useMemo(() => ({ - "negatif": rowsNegatif.length, - "sans-vente": rowsSansVente.length, - "sans-vente-6mois": rowsSansVente6Mois.length, - }), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]); + const tabs: TabItem[] = useMemo(() => [ + { value: "negatif", label: "Stock négatif", count: rowsNegatif.length, alert: true }, + { value: "sans-vente", label: "Entrées sans vente", count: rowsSansVente.length }, + { value: "sans-vente-6mois", label: "Sans vente depuis 6 mois", count: 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 (
- {showLoader && } - - {/* Toolbar */} -
-
- - -
+ {/* Choix du magasin */} +
+ setPassword(e.target.value)} - placeholder="Min. 4 caractères" - className="apple-input pr-10" - required - /> - -
-
- - {/* Confirm */} -
- -
- setConfirm(e.target.value)} - placeholder="Répétez le mot de passe" - className="apple-input pr-10" - required - /> - -
-
- - {/* Password match indicator */} - {confirm.length > 0 && ( -
- - {password === confirm ? "Les mots de passe correspondent" : "Les mots de passe ne correspondent pas"} -
- )} - - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Actions */} -
- - -
- -
-
-
- ); -} diff --git a/src/components/shared/confirm-modal.tsx b/src/components/shared/confirm-modal.tsx deleted file mode 100644 index 7fbafd8..0000000 --- a/src/components/shared/confirm-modal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { X, AlertTriangle } from "lucide-react"; - -interface ConfirmModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title?: string; - message: string; - confirmLabel?: string; - cancelLabel?: string; - variant?: "danger" | "default"; -} - -export function ConfirmModal({ - isOpen, - onClose, - onConfirm, - title = "Confirmation", - message, - confirmLabel = "OK", - cancelLabel = "Annuler", - variant = "default", -}: ConfirmModalProps) { - const [isVisible, setIsVisible] = useState(false); - const confirmBtnRef = useRef(null); - - useEffect(() => { - if (isOpen) { - setIsVisible(true); - setTimeout(() => confirmBtnRef.current?.focus(), 50); - } else { - setIsVisible(false); - } - }, [isOpen]); - - const handleClose = () => { - setIsVisible(false); - setTimeout(onClose, 250); - }; - - const handleConfirm = () => { - setIsVisible(false); - setTimeout(onConfirm, 250); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") handleClose(); - if (e.key === "Enter") handleConfirm(); - }; - - if (!isOpen && !isVisible) return null; - - const isDanger = variant === "danger"; - - return ( -
- {/* Backdrop */} -
- - {/* Panel */} -
- {/* Accent bar */} -
- - {/* Close button */} - - -
- {/* Icon + Title */} -
-
- -
-

{title}

-
- - {/* Message */} -

- {message} -

- - {/* Actions */} -
- - -
-
-
-
- ); -} diff --git a/src/components/shared/loading-modal.tsx b/src/components/shared/loading-modal.tsx deleted file mode 100644 index dc0fd02..0000000 --- a/src/components/shared/loading-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -interface LoadingModalProps { - message?: string; - subMessage?: string; -} - -export default function LoadingModal({ - message = "Chargement des données", - subMessage = "Veuillez patienter...", -}: LoadingModalProps) { - return ( -
-
-
-
-
-
- - {message} - - - {subMessage} - -
-
-
- ); -} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 976ab12..c355d18 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -54,7 +54,7 @@ export function Sidebar() {
{/* Navigation */} -