mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd51ecb88 | ||
|
|
af5933b5a7 | ||
|
|
26b9364232 | ||
|
|
b7ee413d67 | ||
|
|
220ca2263a | ||
|
|
0c890c84c5 | ||
|
|
7da4c6d5fd | ||
|
|
2c84cfda53 | ||
|
|
585e171708 | ||
|
|
2404b22628 | ||
|
|
7f5ef93469 | ||
|
|
60ce3b8d21 | ||
|
|
828a50bfc0 | ||
|
|
f75d51f6f4 | ||
|
|
beefb07ba0 | ||
|
|
dc7f6bbf9f | ||
|
|
06f0bd0513 | ||
|
|
8158eda11b | ||
|
|
c9e1934aae | ||
|
|
9729b00dc7 | ||
|
|
1e7c1ba4cd | ||
|
|
52b663579f | ||
|
|
89d573634a | ||
|
|
0a2bc8318b | ||
|
|
c532386385 | ||
|
|
1f4571bd7a | ||
|
|
2ae523a804 | ||
|
|
92769535dc | ||
|
|
a4e3c53dcc | ||
|
|
6e59458403 | ||
|
|
720cf3c7a1 | ||
|
|
0bd7ec9798 | ||
|
|
be8d576af5 | ||
|
|
fff4cd9641 | ||
|
|
310165151d | ||
|
|
0a57a9a707 | ||
|
|
60aa2ce120 | ||
|
|
8fe294924b | ||
|
|
33b30dc8bd | ||
|
|
73accd6d60 | ||
|
|
3fa92e891b | ||
|
|
94e82aa404 | ||
|
|
18edda7167 | ||
|
|
3a825866d2 | ||
|
|
54d51e8eab | ||
|
|
c565672259 | ||
|
|
4b229acc9b | ||
|
|
a45e09777b | ||
|
|
e79d7e51da |
No files matched your search
@@ -41,3 +41,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
# données locales (configuration avec mots de passe, comptes de secours)
|
||||
/data/
|
||||
Binary file not shown.
@@ -0,0 +1,466 @@
|
||||
# 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<T>`** : généralisé depuis `StockTable<T>` (`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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Suivi — Lot 4 réalisé
|
||||
|
||||
**Grille servie depuis l'instantané `grid_rows`**
|
||||
- `getProductRows` lit d'abord l'instantané du fournisseur (`readGridSnapshot`), puis y reporte :
|
||||
- la gamme FF du moment (INIT) ;
|
||||
- les gammes du dernier enregistrement.
|
||||
- Hors « Nos 2 magasins », le rattrapage du magasin choisi est appliqué comme après un calcul complet.
|
||||
- L'instantané n'est pas utilisé dans trois cas, et la Grille est alors recalculée comme avant :
|
||||
- il est absent ;
|
||||
- il date d'un autre mois (la fenêtre des 12 mois a changé) ;
|
||||
- il est d'un ancien format. Un numéro de version (`payloadVersion`) est désormais écrit dans chaque ligne, donc les instantanés existants seront recalculés une fois, à la première ouverture ou par la synchro de la nuit.
|
||||
- Instantané de plus de 20 h : il est servi tout de suite, et un recalcul part en arrière-plan.
|
||||
- « Actualiser » force toujours le calcul complet.
|
||||
- Mesure locale (30 lignes) : 43 ms depuis l'instantané, 1 ms depuis le cache mémoire.
|
||||
|
||||
**Grille côté navigateur**
|
||||
- Retour sur la Grille (même fournisseur et même magasin, moins de 10 min) : les lignes en mémoire sont réutilisées, sans nouveau téléchargement.
|
||||
- Changement de magasin ou « Actualiser » sur le même fournisseur : la Grille reste affichée et utilisable. Les chiffres complétés remplacent l'affichage d'un bloc, une fois reçus.
|
||||
- `React.memo` sur la Grille, la barre de filtres et la barre du bas. En-tête du tableau mémorisé. Virtualiseur sans `flushSync`.
|
||||
|
||||
**Requêtes**
|
||||
- **Fiche produit** : les sept lectures, y compris le réseau Qlik, partent en même temps au lieu de deux vagues successives.
|
||||
- **Ventes par mois** : filtre sur des plages de dates au lieu de `TO_CHAR(datmvt)`, ce qui permet à PostgreSQL d'utiliser un index sur `datmvt`. Résultats identiques vérifiés sur une base de test (colonnes `date` et `timestamp`, bornes de mois).
|
||||
- **Stock sans vente** : même principe pour la borne « avant le mois en cours ».
|
||||
- **Accueil** :
|
||||
- enrichissement des produits par vagues de 12 appels au lieu de 5 ;
|
||||
- cache porté à 30 min, puisque ce sont les ventes de la veille.
|
||||
- **API `/api/v1`** :
|
||||
- le comptage et la page sont lus en parallèle ;
|
||||
- `last_used_at` d'une clé est écrit au plus une fois par minute ;
|
||||
- la version interne des lignes n'est pas exposée.
|
||||
|
||||
**Non fait volontairement** (à reprendre avec un accès à la base réelle)
|
||||
- Réécritures SQL lourdes : Meilleures ventes par `artnoid`, Stock sans vente agrégé d'abord, tableau de bord en une requête SQL au lieu des appels HTTP. Sans le schéma ni les volumes de la base FF, impossible de vérifier l'équivalence des résultats et le gain par `EXPLAIN ANALYZE`.
|
||||
- Index sur les tables FF (`mouvements.datmvt`, `codein`) : ces tables ne sont pas gérées par CollectFlow. À proposer à l'administrateur de la base après `EXPLAIN ANALYZE`.
|
||||
- Pagination par curseur de `/api/v1/grid` : elle changerait le contrat public (`page`) utilisé par des appelants externes.
|
||||
- Suppression des dépendances inutilisées (`@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `xlsx`) : le dépôt a deux fichiers de verrouillage (npm et pnpm). À faire avec le gestionnaire de paquets réellement utilisé en production.
|
||||
|
||||
**À vérifier en production**
|
||||
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
|
||||
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
|
||||
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
|
||||
|
||||
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
|
||||
|
||||
**Grille : changement de magasin**
|
||||
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
|
||||
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
|
||||
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
|
||||
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
|
||||
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
|
||||
- Il n'est demandé qu'une fois par magasin et par chargement.
|
||||
- **Sélection** : remise à zéro seulement au changement de fournisseur.
|
||||
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
|
||||
|
||||
**Sécurité**
|
||||
- **Contrôle commun `lib/authz.ts`** :
|
||||
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
|
||||
- si la base est injoignable, le jeton de session fait foi.
|
||||
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
|
||||
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
|
||||
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
|
||||
- **Mots de passe et clés d'IA** :
|
||||
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
|
||||
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
|
||||
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
|
||||
- **Compte admin/admin** :
|
||||
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
|
||||
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
|
||||
- `data/` est ignoré par git.
|
||||
|
||||
**À savoir après la mise en ligne**
|
||||
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
|
||||
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
|
||||
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
|
||||
|
||||
## Suivi — Lot 6 réalisé (performances restantes et nettoyage)
|
||||
|
||||
**Grille**
|
||||
- **Écriture des réglages dans le navigateur** : au plus une par demi-seconde, et immédiate quand la page est quittée. Avant, chaque changement d'état en déclenchait une. Mesure : 8 changements de gamme donnent 1 écriture au lieu de 16.
|
||||
- **Calculs faits une seule fois par ligne** : la tendance réseau (au lieu d'un calcul par case et par rendu) et le texte de recherche (au lieu d'un calcul par ligne et par frappe). La petite courbe de tendance est mémorisée.
|
||||
- **Barre du bas** : elle ne suit plus que le nombre de modifications ; le détail est lu au moment d'enregistrer.
|
||||
|
||||
**Commandes fournisseurs**
|
||||
- **Montants de franco** : demandés à l'API FF 6 fournisseurs à la fois, au lieu de tous en même temps.
|
||||
- **Dernière réception** : calculée pour les seuls fournisseurs du cadencier. Sur la base de test, les résultats sont identiques au calcul complet.
|
||||
|
||||
**Nettoyage**
|
||||
- **Dépendances retirées** : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `remark-gfm`, `xlsx`, `@radix-ui/react-dropdown-menu`. Un `npm ci` complet a été vérifié.
|
||||
- **`pnpm-lock.yaml` supprimé** : Docker et le README utilisent npm.
|
||||
- **Code mort retiré** :
|
||||
- `ui/textarea`, `debug/api/init-db`, `score/store` ;
|
||||
- `getGridData` et `getAvailableNomenclature` ;
|
||||
- 5 fonctions de l'ancien client HTTP FF, `pgGetArticlesByCodeCentrale` et `libelleGamme`.
|
||||
- **Journaux à la racine supprimés** : `build.log`, `build_logs.txt`, `error.txt`, `tsc_errors.log`.
|
||||
|
||||
**Non fait volontairement**
|
||||
- **Enrichissement de l'Accueil en SQL** :
|
||||
- le cache de la page sert la version précédente pendant son recalcul, donc le gain serait faible ;
|
||||
- et rien ne garantit que la base FF relie chaque mouvement à son fournisseur (colonne détectée au cas par cas) : le fournisseur affiché pourrait changer.
|
||||
- À reprendre après le diagnostic du lot 7.
|
||||
- **Largeurs de colonnes en variables CSS** : peu de gain maintenant que l'écriture des réglages est différée.
|
||||
|
||||
## Suivi — Lot 7 réalisé (diagnostic de la base FF)
|
||||
|
||||
Les réécritures SQL restantes demandent de connaître la vraie base FF : types des colonnes, index, volumes. Ce dépôt ne les contient pas. Elles concernent :
|
||||
- `TRIM(a.codein::text)` dans une quinzaine de requêtes ;
|
||||
- `TO_CHAR(datmvt)` dans les regroupements ;
|
||||
- la jointure par code plutôt que par identifiant dans Meilleures ventes.
|
||||
|
||||
Le script `scripts/diagnostic-ff.js` les relève, en lecture seule :
|
||||
- version et réglages de PostgreSQL ;
|
||||
- taille des tables et date de dernière analyse ;
|
||||
- types des colonnes de jointure et de filtre ;
|
||||
- index existants ;
|
||||
- nombre de codes article contenant des espaces (pour savoir si `TRIM()` est utile) ;
|
||||
- plans d'exécution de six requêtes représentatives : recherche d'un article par code avec et sans `TRIM`, Meilleures ventes sur 7 jours, dernière réception, ventes mensuelles d'un fournisseur, stock sans vente.
|
||||
|
||||
**À lancer sur le serveur de production** (conteneur `web` du docker-compose) :
|
||||
|
||||
```bash
|
||||
docker compose exec web node scripts/diagnostic-ff.js > diagnostic-ff.txt
|
||||
# Avec les durées réelles (exécute les requêtes, à faire hors heures d'ouverture) :
|
||||
docker compose exec web node scripts/diagnostic-ff.js --analyze > diagnostic-ff-analyze.txt
|
||||
```
|
||||
|
||||
Le fichier produit ne contient aucun mot de passe, seulement des noms de tables, de colonnes et d'index, et des plans d'exécution. Une fois transmis, il permet de décider :
|
||||
- quels `TRIM()` retirer ;
|
||||
- quels index proposer à l'administrateur de la base FF ;
|
||||
- s'il faut remplacer l'enrichissement HTTP de l'Accueil.
|
||||
Generated
+104
-2002
File diff suppressed because it is too large.
Load diff
@@ -12,12 +12,8 @@
|
||||
"docker:build": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.49.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"ai": "^6.0.97",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -34,11 +30,8 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ws": "^8.21.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
Generated
-11081
File diff suppressed because it is too large.
Load diff
@@ -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(`
|
||||
@@ -122,6 +131,87 @@ async function main() {
|
||||
`);
|
||||
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||
|
||||
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
|
||||
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "grid_rows" (
|
||||
"code_fournisseur" varchar(20) NOT NULL,
|
||||
"codein" varchar(20) NOT NULL,
|
||||
"nom_fournisseur" varchar(255),
|
||||
"libelle1" varchar(500),
|
||||
"gtin" varchar(30),
|
||||
"reference" varchar(100),
|
||||
"code_centrale" varchar(20),
|
||||
"code1" varchar(20),
|
||||
"code2" varchar(20),
|
||||
"code3" varchar(20),
|
||||
"code_gamme" varchar(20),
|
||||
"code_gamme_init" varchar(20),
|
||||
"total_ca" numeric(16, 2),
|
||||
"total_quantite" numeric(14, 2),
|
||||
"total_marge" numeric(16, 2),
|
||||
"taux_marge" numeric(8, 4),
|
||||
"stock_actuel" numeric(14, 2),
|
||||
"ca_reseau" numeric(16, 2),
|
||||
"qte_reseau" numeric(14, 2),
|
||||
"nb_magasins_reseau" integer,
|
||||
"ca_par_magasin_reseau" numeric(16, 2),
|
||||
"marge_pct_reseau" numeric(8, 4),
|
||||
"payload" jsonb NOT NULL,
|
||||
"computed_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
|
||||
ON "grid_rows" ("code_fournisseur", "codein");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
|
||||
`);
|
||||
console.log("[DB Init] Table grid_rows is verified/created.");
|
||||
|
||||
// Clés d'API : seul le hachage SHA-256 est stocké.
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"key_prefix" varchar(20) NOT NULL,
|
||||
"key_hash" text NOT NULL UNIQUE,
|
||||
"role" varchar(20) DEFAULT 'user' NOT NULL,
|
||||
"created_by" varchar(50),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"last_used_at" timestamp,
|
||||
"revoked_at" timestamp
|
||||
);
|
||||
`);
|
||||
console.log("[DB Init] Table api_keys is verified/created.");
|
||||
|
||||
// Paramétrage et suivi de la synchronisation nocturne, par fournisseur.
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
|
||||
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
|
||||
"nom_fournisseur" varchar(255),
|
||||
"actif_sql" boolean NOT NULL DEFAULT true,
|
||||
"actif_qlik" boolean NOT NULL DEFAULT true,
|
||||
"desactive_motif" text,
|
||||
"dernier_sql_at" timestamp,
|
||||
"dernier_sql_statut" varchar(20),
|
||||
"dernier_sql_lignes" integer,
|
||||
"dernier_sql_erreur" text,
|
||||
"dernier_sql_ms" integer,
|
||||
"dernier_qlik_at" timestamp,
|
||||
"dernier_qlik_statut" varchar(20),
|
||||
"dernier_qlik_codes" integer,
|
||||
"dernier_qlik_erreur" text,
|
||||
"dernier_qlik_ms" integer,
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
|
||||
`);
|
||||
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule).
|
||||
*
|
||||
* Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche
|
||||
* produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas
|
||||
* dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire
|
||||
* quoi que ce soit.
|
||||
*
|
||||
* Usage (sur le serveur, ou dans le conteneur Docker de l'application) :
|
||||
*
|
||||
* node scripts/diagnostic-ff.js > diagnostic-ff.txt
|
||||
* node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt
|
||||
*
|
||||
* Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus.
|
||||
* `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée
|
||||
* (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture.
|
||||
* La session est en lecture seule et chaque requête est coupée au-delà de 2 min.
|
||||
*
|
||||
* Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL.
|
||||
*/
|
||||
const { Pool } = require("pg");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ANALYZE = process.argv.includes("--analyze");
|
||||
const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"];
|
||||
const COLONNES = {
|
||||
articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"],
|
||||
mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"],
|
||||
cube_stock: ["artnoid", "site", "qte"],
|
||||
artfou1: ["no_id", "art_no_id", "code", "preference"],
|
||||
fouident: ["code"],
|
||||
};
|
||||
|
||||
function connexion() {
|
||||
try {
|
||||
const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json");
|
||||
if (fs.existsSync(fichier)) {
|
||||
const config = JSON.parse(fs.readFileSync(fichier, "utf-8"));
|
||||
if (config.url) return config.url;
|
||||
}
|
||||
} catch {
|
||||
/* configuration illisible : on tente la variable d'environnement */
|
||||
}
|
||||
return process.env.DATABASE_URL || "";
|
||||
}
|
||||
|
||||
function titre(texte) {
|
||||
console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`);
|
||||
}
|
||||
|
||||
async function section(nom, fn) {
|
||||
titre(nom);
|
||||
const debut = Date.now();
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
console.log(` (indisponible : ${e.message})`);
|
||||
}
|
||||
console.log(` [${Date.now() - debut} ms]`);
|
||||
}
|
||||
|
||||
async function plan(client, nom, requete, params = []) {
|
||||
await section(`PLAN — ${nom}`, async () => {
|
||||
const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT";
|
||||
const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params);
|
||||
for (const r of rows) console.log(` ${r["QUERY PLAN"]}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple).
|
||||
const HIT_PARADE = `
|
||||
WITH ventes AS (
|
||||
SELECT TRIM(a.codein::text) AS codein, m.site,
|
||||
SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL
|
||||
GROUP BY TRIM(a.codein::text), m.site
|
||||
),
|
||||
attrs AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
stock_agg AS (
|
||||
SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total
|
||||
FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid
|
||||
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
|
||||
GROUP BY TRIM(a2.codein::text)
|
||||
)
|
||||
SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total
|
||||
FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein
|
||||
ORDER BY v.ca_ttc DESC`;
|
||||
|
||||
const DERNIERE_RECEPTION = `
|
||||
SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL
|
||||
GROUP BY af.code, m.site`;
|
||||
|
||||
const MENSUEL_FOURNISSEUR = `
|
||||
SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt)
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id
|
||||
WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579')
|
||||
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`;
|
||||
|
||||
const STOCK_SANS_VENTE = `
|
||||
SELECT a.codein, m.site, MAX(m.datmvt)
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
|
||||
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE)
|
||||
AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3)
|
||||
GROUP BY a.codein, m.site, cs.qte
|
||||
HAVING COALESCE(cs.qte::float, 0) <> 0`;
|
||||
|
||||
async function main() {
|
||||
const url = connexion();
|
||||
if (!url) {
|
||||
console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL.");
|
||||
process.exit(1);
|
||||
}
|
||||
const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SET default_transaction_read_only = on");
|
||||
await client.query("SET statement_timeout = '120s'");
|
||||
await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application
|
||||
|
||||
console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`);
|
||||
|
||||
await section("Serveur PostgreSQL et réglages", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT name, setting, unit FROM pg_settings
|
||||
WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size',
|
||||
'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target')
|
||||
ORDER BY name`);
|
||||
for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`);
|
||||
});
|
||||
|
||||
await section("Taille des tables (lignes estimées, taille totale avec index)", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille,
|
||||
s.last_analyze, s.last_autoanalyze
|
||||
FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||
WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1)
|
||||
ORDER BY c.relname`, [TABLES]);
|
||||
const trouvees = new Set(rows.map((r) => r.relname));
|
||||
for (const r of rows) {
|
||||
const analyse = r.last_analyze || r.last_autoanalyze;
|
||||
console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`);
|
||||
}
|
||||
for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`);
|
||||
});
|
||||
|
||||
await section("Types des colonnes utilisées dans les jointures et filtres", async () => {
|
||||
for (const [table, colonnes] of Object.entries(COLONNES)) {
|
||||
const { rows } = await client.query(`
|
||||
SELECT column_name, data_type, character_maximum_length, table_schema
|
||||
FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2)
|
||||
ORDER BY column_name`, [table, colonnes]);
|
||||
for (const r of rows) {
|
||||
console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await section("Index existants", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT tablename, indexname, indexdef FROM pg_indexes
|
||||
WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]);
|
||||
for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`);
|
||||
if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)");
|
||||
});
|
||||
|
||||
await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT count(*) AS total,
|
||||
count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces,
|
||||
count(DISTINCT TRIM(codein::text)) AS distincts
|
||||
FROM articles`);
|
||||
const r = rows[0];
|
||||
console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`);
|
||||
try {
|
||||
const { rows: c } = await client.query(`
|
||||
SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces
|
||||
FROM articles WHERE artcentrale IS NOT NULL`);
|
||||
console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`);
|
||||
} catch (e) {
|
||||
console.log(` artcentrale : (indisponible : ${e.message})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Paramètres d'exemple tirés de la base elle-même.
|
||||
let codein = null;
|
||||
let codefou = null;
|
||||
try {
|
||||
codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null;
|
||||
codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null;
|
||||
} catch {
|
||||
/* tables absentes : les plans concernés le signaleront */
|
||||
}
|
||||
const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
await plan(client, `article par code, avec TRIM (codein ${codein})`,
|
||||
"SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]);
|
||||
await plan(client, `article par code, sans TRIM (codein ${codein})`,
|
||||
"SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]);
|
||||
await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]);
|
||||
await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION);
|
||||
await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]);
|
||||
await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Diagnostic interrompu :", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,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 (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 relative overflow-hidden">
|
||||
{/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 blur-[120px] rounded-full" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-emerald-500/10 dark:bg-emerald-500/5 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full flex justify-center">
|
||||
<LoginForm />
|
||||
</div>
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--bg-base)] p-6">
|
||||
<LoginForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Strip HTML tags and convert common HTML constructs to Markdown equivalents
|
||||
function htmlToMarkdown(text: string): string {
|
||||
return text
|
||||
// Block-level → markdown
|
||||
.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`)
|
||||
.replace(/<strong[^>]*>(.*?)<\/strong>/gis, "**$1**")
|
||||
.replace(/<b[^>]*>(.*?)<\/b>/gis, "**$1**")
|
||||
.replace(/<em[^>]*>(.*?)<\/em>/gis, "*$1*")
|
||||
.replace(/<i[^>]*>(.*?)<\/i>/gis, "*$1*")
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`")
|
||||
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```")
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gis, "- $1")
|
||||
.replace(/<ul[^>]*>|<\/ul>/gis, "")
|
||||
.replace(/<ol[^>]*>|<\/ol>/gis, "")
|
||||
.replace(/<tr[^>]*>/gis, "")
|
||||
.replace(/<\/tr>/gis, "\n")
|
||||
.replace(/<th[^>]*>(.*?)<\/th>/gis, "| **$1** ")
|
||||
.replace(/<td[^>]*>(.*?)<\/td>/gis, "| $1 ")
|
||||
.replace(/<table[^>]*>|<\/table>|<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gis, "")
|
||||
.replace(/<br\s*\/?>/gis, "\n")
|
||||
.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n")
|
||||
.replace(/<div[^>]*>(.*?)<\/div>/gis, "$1\n")
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)")
|
||||
// Strip any remaining tags
|
||||
.replace(/<[^>]+>/g, "")
|
||||
// Decode HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
// Clean up excessive blank lines
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
free: boolean;
|
||||
promptPrice: number; // $ per token
|
||||
completionPrice: number;
|
||||
}
|
||||
|
||||
interface ToolEvent {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolEvents?: ToolEvent[];
|
||||
}
|
||||
|
||||
interface SessionUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
|
||||
|
||||
// Compute cost from token counts and model pricing
|
||||
function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number {
|
||||
if (!model) return 0;
|
||||
return promptTokens * model.promptPrice + completionTokens * model.completionPrice;
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd === 0) return "Gratuit";
|
||||
if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`;
|
||||
return `$${usd.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function ToolCallBlock({ event }: { event: ToolEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label = event.name === "execute_sql"
|
||||
? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}`
|
||||
: event.name === "get_db_schema"
|
||||
? "Schéma DB"
|
||||
: event.name;
|
||||
|
||||
return (
|
||||
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
|
||||
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
|
||||
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && event.result && (
|
||||
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||
{event.result}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: Message }) {
|
||||
const isUser = msg.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
|
||||
)}>
|
||||
{isUser
|
||||
? <User className="w-4 h-4" />
|
||||
: <Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||
{/* Tool events (assistant only) */}
|
||||
{msg.toolEvents && msg.toolEvents.length > 0 && (
|
||||
<div className="w-full mb-2">
|
||||
{msg.toolEvents.map((ev, i) => (
|
||||
<ToolCallBlock key={i} event={ev} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text bubble */}
|
||||
{msg.content && (
|
||||
<div className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm leading-relaxed break-words",
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white rounded-tr-md"
|
||||
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
|
||||
)}>
|
||||
{isUser ? msg.content : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
h1: ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-0.5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-0.5">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-2">{children}</li>,
|
||||
code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) =>
|
||||
inline
|
||||
? <code className="px-1 py-0.5 rounded bg-[var(--bg-base)] border border-[var(--border)] font-mono text-xs text-[var(--accent)]">{children}</code>
|
||||
: <pre className="my-2 p-3 rounded-lg bg-[var(--bg-base)] border border-[var(--border)] overflow-x-auto"><code className="font-mono text-xs text-[var(--text-primary)] whitespace-pre">{children}</code></pre>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)] pl-3 my-2 text-[var(--text-secondary)] italic">{children}</blockquote>,
|
||||
table: ({ children }) => <div className="overflow-x-auto my-2"><table className="w-full text-xs border-collapse">{children}</table></div>,
|
||||
thead: ({ children }) => <thead className="bg-[var(--bg-base)]">{children}</thead>,
|
||||
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 text-left font-semibold">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="border-[var(--border)] my-3" />,
|
||||
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent)] underline hover:opacity-80">{children}</a>,
|
||||
}}
|
||||
>
|
||||
{htmlToMarkdown(msg.content)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function AdminAiChatPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
|
||||
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingModels, setLoadingModels] = useState(true);
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
|
||||
|
||||
// Fetch config + models
|
||||
useEffect(() => {
|
||||
setLoadingModels(true);
|
||||
fetch("/api/admin/ai-config")
|
||||
.then(r => r.json())
|
||||
.then(async d => {
|
||||
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
|
||||
setAiProviderState(provider);
|
||||
if (provider === "google") {
|
||||
const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({
|
||||
id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0,
|
||||
})));
|
||||
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
|
||||
} else {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
|
||||
}
|
||||
})
|
||||
.catch(async () => {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
})
|
||||
.finally(() => setLoadingModels(false));
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, loading]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMsg: Message = { role: "user", content: input.trim() };
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setLoading(true);
|
||||
|
||||
// Build messages to send (excluding tool events, which are local display only)
|
||||
const apiMessages = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] };
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages, model: selectedModel }),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Erreur serveur");
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const pendingToolEvents: ToolEvent[] = [];
|
||||
let lastToolStart: ToolEvent | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
|
||||
if (event.type === "text") {
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.content += event.content;
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
} else if (event.type === "tool_start") {
|
||||
lastToolStart = { name: event.name, args: event.args };
|
||||
} else if (event.type === "tool_result") {
|
||||
if (lastToolStart) {
|
||||
const ev: ToolEvent = { ...lastToolStart, result: event.result };
|
||||
pendingToolEvents.push(ev);
|
||||
lastToolStart = null;
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.toolEvents = [...pendingToolEvents];
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "usage") {
|
||||
const pt = event.prompt_tokens ?? 0;
|
||||
const ct = event.completion_tokens ?? 0;
|
||||
const cost = computeCost(pt, ct, selectedModelInfo);
|
||||
setSessionUsage(prev => ({
|
||||
promptTokens: prev.promptTokens + pt,
|
||||
completionTokens: prev.completionTokens + ct,
|
||||
costUsd: prev.costUsd + cost,
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [input, loading, messages, selectedModel, selectedModelInfo]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([]);
|
||||
setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
setError(null);
|
||||
if (loading && abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 text-[var(--accent)]" />
|
||||
Admin AI Chat
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Accès direct à la base de données · Admin uniquement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage / Cost */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
|
||||
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
|
||||
<span className="text-[var(--text-muted)]">session</span>
|
||||
{sessionUsage.promptTokens > 0 && (
|
||||
<span className="text-[var(--text-muted)] ml-1">
|
||||
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
|
||||
aiProvider === "google"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "bg-[var(--accent-bg)] text-[var(--accent)]"
|
||||
)}>
|
||||
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
|
||||
</span>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Chargement des modèles…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={e => setSelectedModel(e.target.value)}
|
||||
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} {m.free ? "(Gratuit)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedModelInfo && !selectedModelInfo.free && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
|
||||
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
<span>·</span>
|
||||
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedModelInfo?.free && (
|
||||
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<Bot className="w-8 h-8 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-md">
|
||||
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
|
||||
{[
|
||||
"Combien de produits par fournisseur ?",
|
||||
"Quels sont les 10 produits avec le plus de CA ?",
|
||||
"Montre-moi les snapshots récents",
|
||||
"Répartition des gammes A/B/C/D/Z",
|
||||
].map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
|
||||
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
|
||||
{loading && messages[messages.length - 1]?.role !== "assistant" && (
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="mt-3 shrink-0">
|
||||
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: "40px" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || loading}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
|
||||
>
|
||||
{loading
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Send className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
|
||||
L'IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle2, Database, Network, Clock,
|
||||
} from "lucide-react";
|
||||
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;
|
||||
heureDebut: number;
|
||||
heureFin: number;
|
||||
qlikParNuit: number;
|
||||
sqlParQlik: number;
|
||||
qlikMinJours: number;
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
enCours: boolean;
|
||||
origine: "auto" | "manuel" | null;
|
||||
debutAt: string | null;
|
||||
finAt: string | null;
|
||||
fournisseurCourant: string | null;
|
||||
etape: "sql" | "qlik" | null;
|
||||
sqlFaits: number;
|
||||
sqlEchecs: number;
|
||||
qlikFaits: number;
|
||||
qlikEchecs: number;
|
||||
tourTermine: boolean;
|
||||
derniereErreur: string | null;
|
||||
}
|
||||
|
||||
interface FournisseurRow {
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string | null;
|
||||
actifSql: boolean;
|
||||
actifQlik: boolean;
|
||||
desactiveMotif: string | null;
|
||||
dernierSqlAt: string | null;
|
||||
dernierSqlStatut: string | null;
|
||||
dernierSqlLignes: number | null;
|
||||
dernierSqlMs: number | null;
|
||||
dernierQlikAt: string | null;
|
||||
dernierQlikStatut: string | null;
|
||||
dernierQlikCodes: number | null;
|
||||
}
|
||||
|
||||
interface Resume {
|
||||
total: number; actifsSql: number; actifsQlik: number;
|
||||
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
|
||||
}
|
||||
|
||||
type Filtre = "tous" | "actifs" | "inactifs" | "echecs";
|
||||
|
||||
/** 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<string, { ton: Ton; libelle: string; aide: string }> = {
|
||||
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 ? <Badge ton={s.ton} title={s.aide}>{s.libelle}</Badge> : <Badge>{statut}</Badge>;
|
||||
}
|
||||
|
||||
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() {
|
||||
const [settings, setSettings] = useState<SyncSettings | null>(null);
|
||||
const [state, setState] = useState<SyncState | null>(null);
|
||||
const [dansLaFenetre, setDansLaFenetre] = useState(false);
|
||||
const [rows, setRows] = useState<FournisseurRow[]>([]);
|
||||
const [resume, setResume] = useState<Resume | null>(null);
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const [chargement, setChargement] = useState(true);
|
||||
const [recherche, setRecherche] = useState("");
|
||||
const [filtre, setFiltre] = useState<Filtre>("tous");
|
||||
const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null);
|
||||
|
||||
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
|
||||
const lireEtat = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/sync");
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
return res.json();
|
||||
}, []);
|
||||
|
||||
const lireFournisseurs = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/sync/fournisseurs");
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
return res.json();
|
||||
}, []);
|
||||
|
||||
const rafraichirEtat = useCallback(() => {
|
||||
lireEtat()
|
||||
.then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); })
|
||||
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)));
|
||||
}, [lireEtat]);
|
||||
|
||||
const rafraichirTout = useCallback(() => {
|
||||
Promise.all([lireEtat(), lireFournisseurs()])
|
||||
.then(([e, f]) => {
|
||||
setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre);
|
||||
setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null);
|
||||
})
|
||||
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setChargement(false));
|
||||
}, [lireEtat, lireFournisseurs]);
|
||||
|
||||
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
|
||||
|
||||
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);
|
||||
return () => clearInterval(id);
|
||||
}, [state?.enCours, rafraichirEtat]);
|
||||
|
||||
// Saisie numérique pas encore envoyée (cf. majSettingsDifferee).
|
||||
const patchEnAttente = useRef<Partial<SyncSettings>>({});
|
||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
||||
setSettings({ ...d.settings, ...patchEnAttente.current });
|
||||
} 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
|
||||
// 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<SyncSettings>) => {
|
||||
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) => {
|
||||
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) {
|
||||
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) => {
|
||||
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)
|
||||
? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) }
|
||||
: r)));
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync/fournisseurs", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codes, [champ]: valeur }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
} catch (e) {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const visibles = useMemo(() => {
|
||||
const q = recherche.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
|
||||
return correspond(r, filtre);
|
||||
});
|
||||
}, [rows, recherche, filtre]);
|
||||
|
||||
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const filtres: readonly TabItem<Filtre>[] = 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]);
|
||||
|
||||
const colonnes: DataColumn<FournisseurRow>[] = [
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur,
|
||||
cell: (r) => (
|
||||
<div className="min-w-[180px]">
|
||||
<div className="font-medium text-[var(--text-primary)]">{r.nomFournisseur ?? r.codeFournisseur}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{r.codeFournisseur}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.actifSql}
|
||||
className={CASE}
|
||||
aria-label={`Mettre à jour les ventes de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dernierSql",
|
||||
header: "Dernière mise à jour des ventes",
|
||||
sortValue: (r) => r.dernierSqlAt,
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<StatutBadge statut={r.dernierSqlStatut} />
|
||||
{fmtDate(r.dernierSqlAt)}
|
||||
</div>
|
||||
{r.dernierSqlLignes != null && (
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.actifQlik}
|
||||
className={CASE}
|
||||
aria-label={`Mettre à jour les données réseau de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dernierQlik",
|
||||
header: "Dernière mise à jour réseau",
|
||||
sortValue: (r) => r.dernierQlikAt,
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<StatutBadge statut={r.dernierQlikStatut} />
|
||||
{fmtDate(r.dernierQlikAt)}
|
||||
</div>
|
||||
{r.dernierQlikCodes != null && (
|
||||
<div className="text-xs text-[var(--text-muted)]">{fmtEntier(r.dernierQlikCodes)} produits</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "remarque",
|
||||
header: "Remarque",
|
||||
cell: (r) => (r.desactiveMotif
|
||||
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
|
||||
: null),
|
||||
},
|
||||
];
|
||||
|
||||
if (chargement) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings && erreur) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||
<ErrorState
|
||||
title="Les réglages de la synchronisation n'ont pas pu être chargés"
|
||||
detail={erreur}
|
||||
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl space-y-6">
|
||||
<PageHeader
|
||||
icon={CalendarClock}
|
||||
title="Synchronisation"
|
||||
description={DESCRIPTION}
|
||||
actions={state?.enCours ? (
|
||||
<Button variant="outline" onClick={() => 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" ? <Loader2 className="animate-spin" /> : <Square />}
|
||||
Arrêter
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={lancer} disabled={actionEnCours === "start" || !settings}>
|
||||
{actionEnCours === "start" ? <Loader2 className="animate-spin" /> : <Play />}
|
||||
Lancer maintenant
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{erreur && (
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)] px-4 py-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-[var(--accent-error)]" aria-hidden />
|
||||
<span className="flex-1 text-[var(--text-primary)]">
|
||||
Les informations n'ont pas pu être actualisées : <span className="text-[var(--text-secondary)]">{erreur}</span>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={rafraichirTout}>Réessayer</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Réglages ── */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Réglages"
|
||||
description="Chaque nuit, dans la plage horaire choisie, les fournisseurs sont mis à jour un par un, en commençant par ceux dont les données sont les plus anciennes. Si la nuit ne suffit pas, la suivante reprend là où la précédente s'est arrêtée."
|
||||
/>
|
||||
<CardContent className="space-y-5">
|
||||
<label htmlFor="sync-actif" className="inline-flex cursor-pointer items-center gap-2.5">
|
||||
<input id="sync-actif" type="checkbox" checked={settings.actif}
|
||||
onChange={(e) => majSettings({ actif: e.target.checked })} className={CASE} />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
Synchronisation nocturne activée
|
||||
</span>
|
||||
<Badge ton={settings.actif ? "succes" : "neutre"}>{settings.actif ? "Activée" : "Désactivée"}</Badge>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="sync-debut">Plage horaire</Label>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>de</span>
|
||||
<Input id="sync-debut" type="number" min={0} max={23} value={settings.heureDebut}
|
||||
aria-label="Heure de début"
|
||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 text-center tabular-nums" />
|
||||
<span>h à</span>
|
||||
<Input id="sync-fin" type="number" min={0} max={23} value={settings.heureFin}
|
||||
aria-label="Heure de fin"
|
||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 text-center tabular-nums" />
|
||||
<span>h</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sync-qlik-nuit">Extractions réseau par nuit</Label>
|
||||
<Input id="sync-qlik-nuit" type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 text-center tabular-nums" />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sync-qlik-jours">Fraîcheur des données réseau</Label>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>Actualiser les données réseau de plus de</span>
|
||||
<Input id="sync-qlik-jours" type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 text-center tabular-nums" />
|
||||
<span>jours</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)]">Les modifications sont enregistrées automatiquement.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Lancement en cours ou dernier lancement ── */}
|
||||
{state && (
|
||||
<Card className={state.enCours ? "border-[var(--accent-border)] bg-[var(--accent-bg)]" : undefined}>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{state.enCours
|
||||
? <Loader2 className="h-5 w-5 animate-spin text-[var(--accent)]" aria-hidden />
|
||||
: state.finAt
|
||||
? <CheckCircle2 className="h-5 w-5 text-[var(--accent-success)]" aria-hidden />
|
||||
: <Clock className="h-5 w-5 text-[var(--text-muted)]" aria-hidden />}
|
||||
<span className="text-base font-semibold text-[var(--text-primary)]">
|
||||
{state.enCours
|
||||
? `Lancement${libelleOrigine(state.origine)} en cours`
|
||||
: state.finAt
|
||||
? `Dernier lancement${libelleOrigine(state.origine)} terminé`
|
||||
: "Aucun lancement depuis le démarrage du serveur"}
|
||||
</span>
|
||||
{state.enCours && state.fournisseurCourant && (
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
: {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant}
|
||||
</span>
|
||||
)}
|
||||
{state.tourTermine && (
|
||||
<Badge ton="succes">Tous les fournisseurs ont été traités</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(state.enCours || state.finAt) && (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-[var(--text-secondary)]">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||
{VENTES} : {fmtEntier(state.sqlFaits)} à jour
|
||||
{state.sqlEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.sqlEchecs)} en échec</span>}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Network className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||
{RESEAU} : {fmtEntier(state.qlikFaits)} à jour
|
||||
{state.qlikEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.qlikEchecs)} en échec</span>}
|
||||
</span>
|
||||
{state.debutAt && <span>Commencé le {fmtDate(state.debutAt)}</span>}
|
||||
{!state.enCours && state.finAt && <span>Terminé le {fmtDate(state.finAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.derniereErreur && (
|
||||
<p className="text-[13px] text-[var(--accent-error)]">Dernière erreur : {state.derniereErreur}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{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."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Résumé ── */}
|
||||
{resume && (
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard label="Fournisseurs" value={fmtEntier(resume.total)} />
|
||||
<StatCard label={`${VENTES} activées`} value={fmtEntier(resume.actifsSql)} />
|
||||
<StatCard label={`${RESEAU} activé`} value={fmtEntier(resume.actifsQlik)} />
|
||||
<StatCard label="Ventes jamais mises à jour" value={fmtEntier(resume.jamaisSql)} />
|
||||
<StatCard
|
||||
label={
|
||||
<Tooltip content="Fournisseurs sans aucun article dans la base FF : ils ont été retirés de la mise à jour nocturne. Cochez-les à nouveau pour les réintégrer.">
|
||||
<span tabIndex={0} className="cursor-help underline decoration-dotted underline-offset-2">Retirés automatiquement</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={fmtEntier(resume.desactivesAuto)}
|
||||
hint="aucun article dans la base FF"
|
||||
/>
|
||||
<StatCard
|
||||
label="En échec"
|
||||
value={fmtEntier(resume.enEchecSql + resume.enEchecQlik)}
|
||||
hint={`${fmtEntier(resume.enEchecSql)} ventes, ${fmtEntier(resume.enEchecQlik)} réseau`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fournisseurs ── */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Fournisseurs"
|
||||
description="Cochez, pour chaque fournisseur, les données à mettre à jour chaque nuit."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => action("seed")} disabled={actionEnCours === "seed"}
|
||||
title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms">
|
||||
{actionEnCours === "seed" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Actualiser la liste des fournisseurs
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2">
|
||||
<span className="text-[13px] text-[var(--text-secondary)]">
|
||||
Pour les <strong className="text-[var(--text-primary)]">{fmtEntier(visibles.length)}</strong> fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous :
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifSql", true)}>
|
||||
Activer les ventes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifSql", false)}>
|
||||
Désactiver les ventes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||
onClick={() => basculerEnMasse("actifQlik", false)}>
|
||||
Désactiver le réseau
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
rows={visibles}
|
||||
columns={colonnes}
|
||||
rowKey={(r) => r.codeFournisseur}
|
||||
pageSize={0}
|
||||
unite="fournisseurs"
|
||||
toolbar={
|
||||
<>
|
||||
<Segmented items={filtres} value={filtre} onChange={setFiltre} />
|
||||
<SearchInput
|
||||
value={recherche}
|
||||
onChange={setRecherche}
|
||||
placeholder="Code ou nom du fournisseur"
|
||||
aria-label="Rechercher un fournisseur"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
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 »."}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DESCRIPTION =
|
||||
"Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik).";
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
import { SynchronisationClient } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Synchronisation" };
|
||||
|
||||
/**
|
||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
|
||||
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
|
||||
*/
|
||||
export default async function SynchronisationPage() {
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
return <SynchronisationClient />;
|
||||
}
|
||||
@@ -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 <span className="text-gray-400">N/A</span>;
|
||||
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
|
||||
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "ca" | "caN1">> = {
|
||||
"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<ModeAnalytics>[] = [
|
||||
{ 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 <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur0(v)}</span>;
|
||||
}
|
||||
|
||||
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`${bgColor} ${textColor} inline-block rounded px-2 py-1 text-sm font-semibold`}>
|
||||
{isPositive ? "+" : ""}
|
||||
{value.toFixed(1)}%
|
||||
<span className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||
<span className="inline-flex items-center gap-1">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<DataColumn<AnalyticsRow>[]>(() => [
|
||||
{
|
||||
id: "label",
|
||||
header: libelleColonne,
|
||||
sortValue: (r) => r.label,
|
||||
cell: (r) => <span className="font-medium">{r.label}</span>,
|
||||
footer: () => "Total",
|
||||
className: "min-w-[220px]",
|
||||
},
|
||||
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
|
||||
const total = g.id === "total";
|
||||
const bord = "border-l border-[var(--border)]";
|
||||
return [
|
||||
{
|
||||
id: `${g.id}-ca`,
|
||||
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
|
||||
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
|
||||
align: "right",
|
||||
sortValue: (r) => r[g.ca],
|
||||
cell: (r) => <Montant v={r[g.ca]} />,
|
||||
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
|
||||
className: cn(bord, total && "font-semibold"),
|
||||
headerClassName: bord,
|
||||
},
|
||||
{
|
||||
id: `${g.id}-n1`,
|
||||
header: (
|
||||
<EnTete groupe={g.titre}>
|
||||
{libelleMoisN1} · <Terme id="n1" />
|
||||
</EnTete>
|
||||
),
|
||||
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) => <Montant v={r[g.caN1]} />,
|
||||
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
|
||||
className: cn(total && "font-semibold"),
|
||||
},
|
||||
{
|
||||
id: `${g.id}-evolution`,
|
||||
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
|
||||
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) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
|
||||
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
|
||||
},
|
||||
];
|
||||
}),
|
||||
], [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 (
|
||||
<div className="space-y-6">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-6 rounded-lg bg-white p-4 shadow">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-semibold text-gray-700">Vue :</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => handleModeChange(e.target.value)}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="fournisseur">Fournisseur</option>
|
||||
<option value="nomenclature">Nomenclature</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-semibold text-gray-700">Mois :</label>
|
||||
<select
|
||||
value={mois}
|
||||
onChange={(e) => handleMonthChange(e.target.value)}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{months.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
Comparaison N-1 : {moisN1}
|
||||
<div className="space-y-4" aria-busy={navigation}>
|
||||
{/* Contrôles (relancent le calcul côté serveur) */}
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div role="group" aria-labelledby="analytics-vue">
|
||||
<span id="analytics-vue" className="mb-1 block text-[13px] font-medium text-[var(--text-secondary)]">
|
||||
Afficher par
|
||||
</span>
|
||||
<Segmented items={MODES} value={mode} onChange={handleModeChange} disabled={navigation} />
|
||||
</div>
|
||||
<Select
|
||||
id="analytics-mois"
|
||||
label="Mois"
|
||||
value={mois}
|
||||
onChange={handleMonthChange}
|
||||
options={moisDisponibles}
|
||||
disabled={navigation}
|
||||
/>
|
||||
{navigation && (
|
||||
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Chargement…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
|
||||
{mode === "fournisseur" ? "Fournisseur" : "Nomenclature"}
|
||||
</th>
|
||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
||||
Frouard/Nancy (292)
|
||||
</th>
|
||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
||||
Houdemont (579)
|
||||
</th>
|
||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
||||
Total Réseau
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600"></th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{mois}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{moisN1}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
||||
Évol.
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{mois}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{moisN1}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
||||
Évol.
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{mois}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
||||
{moisN1}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
||||
Évol.
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pivotted.map((row, idx) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={idx % 2 === 0 ? "border-b border-gray-100 bg-white" : "border-b border-gray-100 bg-gray-50"}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{row.label}</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca292)}</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_292)}</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge
|
||||
value={
|
||||
row.caN1_292 > 0
|
||||
? ((row.ca292 - row.caN1_292) / row.caN1_292) * 100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca579)}</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_579)}</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge
|
||||
value={
|
||||
row.caN1_579 > 0
|
||||
? ((row.ca579 - row.caN1_579) / row.caN1_579) * 100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
|
||||
{formatCA(row.caTotal)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
|
||||
{formatCA(row.caN1Total)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge value={row.evolutionTotal} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-bold text-gray-900">TOTAL</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.ca292)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.caN1_292)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge value={evolutionTotal292} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.ca579)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.caN1_579)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge value={evolutionTotal579} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.caTotal)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
||||
{formatCA(totals.caN1Total)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">
|
||||
<EvolutionBadge value={evolutionTotalReseau} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Chiffre d'affaires TTC de <strong className="font-semibold text-[var(--text-primary)]">{libelleMois}</strong>,
|
||||
comparé à <strong className="font-semibold text-[var(--text-primary)]">{libelleMoisN1}</strong> (même mois
|
||||
l'année précédente). Les retours clients sont déduits.
|
||||
</p>
|
||||
|
||||
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
|
||||
<DataTable<AnalyticsRow>
|
||||
key={mode}
|
||||
rows={pivotted}
|
||||
columns={colonnes}
|
||||
rowKey={(r) => `${r.key}|${r.label}`}
|
||||
searchIn={recherche}
|
||||
searchPlaceholder={mode === "fournisseur" ? "Rechercher un fournisseur…" : "Rechercher une famille…"}
|
||||
pageSize={100}
|
||||
unite={mode === "fournisseur" ? "fournisseurs" : "familles"}
|
||||
showFooter
|
||||
emptyTitle={`Aucune vente en ${libelleMois}`}
|
||||
emptyDescription="Aucun chiffre d'affaires n'est enregistré pour ce mois ni pour le même mois de l'année précédente. Choisissez un autre mois."
|
||||
onExport={exporter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function AnalyticsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des statistiques analytiques..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={10} />;
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CalendarRange, RotateCcw } from "lucide-react";
|
||||
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
||||
import { AnalyticsClient } 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 { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Ventes par mois" };
|
||||
|
||||
const NOMS_MOIS = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
];
|
||||
|
||||
/** « 2026-10 » → « octobre 2026 » (sans dépendre du fuseau horaire). */
|
||||
function libelleMois(mois: string): string {
|
||||
const [year, month] = mois.split("-").map(Number);
|
||||
const nom = NOMS_MOIS[month - 1];
|
||||
return nom && Number.isFinite(year) ? `${nom} ${year}` : mois;
|
||||
}
|
||||
|
||||
function formatMois(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Calcul du mois N-1 (même mois année précédente)
|
||||
function getMoisN1(mois: string): string {
|
||||
@@ -14,17 +39,7 @@ function pivotData(
|
||||
mois: string,
|
||||
moisN1: string,
|
||||
mode: string
|
||||
): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
ca292: number;
|
||||
caN1_292: number;
|
||||
ca579: number;
|
||||
caN1_579: number;
|
||||
caTotal: number;
|
||||
caN1Total: number;
|
||||
evolutionTotal: number | null;
|
||||
}> {
|
||||
): AnalyticsRow[] {
|
||||
const map = new Map<string, { code: string; label: string; data: Record<string, number> }>();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -71,77 +86,87 @@ export default async function AnalyticsPage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const mode = (searchParams.mode as string) || "fournisseur";
|
||||
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
|
||||
const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature";
|
||||
const moisParam = (searchParams.mois as string) || "";
|
||||
|
||||
// Mois par défaut = mois courant (YYYY-MM)
|
||||
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
|
||||
// cours, incomplet, se comparerait mal à un mois N-1 entier.
|
||||
const today = new Date();
|
||||
const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
const mois = moisParam || currentMois;
|
||||
const currentMois = formatMois(today);
|
||||
const dernierMoisComplet = formatMois(new Date(today.getFullYear(), today.getMonth() - 1));
|
||||
const mois = moisParam || dernierMoisComplet;
|
||||
const moisN1 = getMoisN1(mois);
|
||||
|
||||
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
|
||||
[];
|
||||
|
||||
if (mode === "fournisseur") {
|
||||
const result = await pgGetCaByFournisseur(mois, moisN1);
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: r.nom,
|
||||
site: r.site,
|
||||
mois: r.mois,
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
} else {
|
||||
const result = await pgGetCaByNomenclature(mois, moisN1);
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: `${r.code} — ${r.libelle}`,
|
||||
site: r.site,
|
||||
mois: r.mois,
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
// Liste des 24 derniers mois (calculée ici : même résultat côté serveur et navigateur).
|
||||
const moisDisponibles: OptionMois[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const value = formatMois(new Date(today.getFullYear(), today.getMonth() - i));
|
||||
moisDisponibles.push({ value, label: libelleMois(value) + (value === currentMois ? " (en cours)" : "") });
|
||||
}
|
||||
if (!moisDisponibles.some((m) => m.value === mois)) {
|
||||
moisDisponibles.push({ value: mois, label: libelleMois(mois) });
|
||||
}
|
||||
|
||||
const pivotted = pivotData(rows, mois, moisN1, mode);
|
||||
let pivotted: AnalyticsRow[] | null = null;
|
||||
let erreur: string | null = null;
|
||||
try {
|
||||
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
|
||||
[];
|
||||
|
||||
// Calcul des totaux
|
||||
const totals = pivotted.reduce(
|
||||
(acc, row) => ({
|
||||
ca292: acc.ca292 + row.ca292,
|
||||
caN1_292: acc.caN1_292 + row.caN1_292,
|
||||
ca579: acc.ca579 + row.ca579,
|
||||
caN1_579: acc.caN1_579 + row.caN1_579,
|
||||
caTotal: acc.caTotal + row.caTotal,
|
||||
caN1Total: acc.caN1Total + row.caN1Total,
|
||||
}),
|
||||
{ ca292: 0, caN1_292: 0, ca579: 0, caN1_579: 0, caTotal: 0, caN1Total: 0 }
|
||||
);
|
||||
if (mode === "fournisseur") {
|
||||
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: r.nom,
|
||||
site: r.site,
|
||||
mois: r.mois,
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
} else {
|
||||
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: `${r.code} — ${r.libelle}`,
|
||||
site: r.site,
|
||||
mois: r.mois,
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
}
|
||||
|
||||
const evolutionTotal292 =
|
||||
totals.caN1_292 > 0 ? ((totals.ca292 - totals.caN1_292) / totals.caN1_292) * 100 : null;
|
||||
const evolutionTotal579 =
|
||||
totals.caN1_579 > 0 ? ((totals.ca579 - totals.caN1_579) / totals.caN1_579) * 100 : null;
|
||||
const evolutionTotalReseau =
|
||||
totals.caN1Total > 0 ? ((totals.caTotal - totals.caN1Total) / totals.caN1Total) * 100 : null;
|
||||
pivotted = pivotData(rows, mois, moisN1, mode);
|
||||
} catch (e) {
|
||||
console.error("[analytics] chargement impossible :", e);
|
||||
erreur = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Analytics</h1>
|
||||
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={CalendarRange}
|
||||
title="Ventes par mois"
|
||||
description="Le chiffre d'affaires TTC d'un mois comparé au même mois de l'année précédente, par fournisseur ou par famille de produits, pour chacun de nos magasins."
|
||||
/>
|
||||
{pivotted ? (
|
||||
<AnalyticsClient
|
||||
mode={mode}
|
||||
mois={mois}
|
||||
moisN1={moisN1}
|
||||
currentMois={currentMois}
|
||||
libelleMois={libelleMois(mois)}
|
||||
libelleMoisN1={libelleMois(moisN1)}
|
||||
moisDisponibles={moisDisponibles}
|
||||
pivotted={pivotted}
|
||||
totals={totals}
|
||||
evolutionTotal292={evolutionTotal292}
|
||||
evolutionTotal579={evolutionTotal579}
|
||||
evolutionTotalReseau={evolutionTotalReseau}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorState
|
||||
title="Les ventes du mois n'ont pas pu être chargées"
|
||||
detail={erreur ?? undefined}
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/analytics?mode=${mode}&mois=${mois}`}><RotateCcw /> Réessayer</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
Power,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
||||
import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react";
|
||||
import {
|
||||
upsertCadence,
|
||||
toggleCadence,
|
||||
@@ -18,41 +9,76 @@ import {
|
||||
type CadenceView,
|
||||
type CadenceStatut,
|
||||
} from "@/features/commandes-auto/actions";
|
||||
import { Badge, type Ton } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmer, toast } from "@/components/ui/feedback";
|
||||
import { Input, Label } from "@/components/ui/form-controls";
|
||||
import { EmptyState } from "@/components/ui/states";
|
||||
import { Terme, Tooltip, InfoBulle } from "@/components/ui/tooltip";
|
||||
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITES: { code: string; label: string }[] = [
|
||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
||||
{ code: "579", label: "579 — Houdemont" },
|
||||
];
|
||||
type Fournisseur = { code: string; nom: string };
|
||||
type Resultat = { ok: boolean; error?: string };
|
||||
|
||||
const SEMAINES_MIN = 1;
|
||||
const SEMAINES_MAX = 104;
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
const pluriel = (n: number, mot: string) => `${n} ${mot}${Math.abs(n) > 1 ? "s" : ""}`;
|
||||
|
||||
function tempsRestant(jours: number | null): string {
|
||||
if (jours === null) return "Réception inconnue";
|
||||
if (jours === 0) return "Aujourd'hui";
|
||||
if (jours < 0) return `En retard de ${Math.abs(jours)} j`;
|
||||
if (jours < 0) return `En retard de ${pluriel(Math.abs(jours), "jour")}`;
|
||||
if (jours === 1) return "Demain";
|
||||
return `Dans ${jours} j`;
|
||||
return `Dans ${pluriel(jours, "jour")}`;
|
||||
}
|
||||
|
||||
const STATUT_META: Record<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
|
||||
a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle },
|
||||
bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock },
|
||||
ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 },
|
||||
inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle },
|
||||
const STATUT_META: Record<CadenceStatut, { libelle: string; ton: Ton; explication: string }> = {
|
||||
a_commander: {
|
||||
libelle: "À commander",
|
||||
ton: "erreur",
|
||||
explication: "L'échéance est atteinte ou dépassée : passez commande.",
|
||||
},
|
||||
bientot: {
|
||||
libelle: "Bientôt",
|
||||
ton: "alerte",
|
||||
explication: "L'échéance tombe dans les 7 prochains jours.",
|
||||
},
|
||||
ok: {
|
||||
libelle: "À jour",
|
||||
ton: "succes",
|
||||
explication: "L'échéance est dans plus de 7 jours.",
|
||||
},
|
||||
inconnu: {
|
||||
libelle: "Inconnu",
|
||||
ton: "neutre",
|
||||
explication: "Aucune réception connue pour ce fournisseur dans ce magasin : l'échéance ne peut pas être calculée.",
|
||||
},
|
||||
};
|
||||
|
||||
function StatutBadge({ statut }: { statut: CadenceStatut }) {
|
||||
const { label, cls, Icon } = STATUT_META[statut];
|
||||
function StatutBadge({ cadence }: { cadence: CadenceView }) {
|
||||
if (!cadence.actif) {
|
||||
return <Badge title="Rappel en pause : aucune alerte pour ce fournisseur.">Désactivé</Badge>;
|
||||
}
|
||||
const { libelle, ton, explication } = STATUT_META[cadence.statut];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
|
||||
<Icon className="h-3 w-3" /> {label}
|
||||
</span>
|
||||
<Badge ton={ton} title={explication}>
|
||||
{cadence.statut === "a_commander" && <AlertTriangle className="h-3.5 w-3.5" aria-hidden />}
|
||||
{libelle}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */
|
||||
|
||||
/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */
|
||||
function trier(rows: CadenceView[]): CadenceView[] {
|
||||
const rank: Record<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
|
||||
@@ -65,248 +91,365 @@ function trier(rows: CadenceView[]): CadenceView[] {
|
||||
});
|
||||
}
|
||||
|
||||
function AddCadenceForm({
|
||||
function FormulaireAjout({
|
||||
site,
|
||||
fournisseurs,
|
||||
existingCodes,
|
||||
onDone,
|
||||
codesExistants,
|
||||
onAjouter,
|
||||
pending,
|
||||
}: {
|
||||
site: string;
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
existingCodes: Set<string>;
|
||||
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
|
||||
fournisseurs: Fournisseur[];
|
||||
codesExistants: Set<string>;
|
||||
onAjouter: (input: { codefou: string; nomfou: string; semaines: number }) => Promise<boolean>;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [semaines, setSemaines] = useState(4);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const listId = `fou-list-${site}`;
|
||||
const [saisie, setSaisie] = useState("");
|
||||
const [semaines, setSemaines] = useState("4");
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const idFournisseur = `cadence-fournisseur-${site}`;
|
||||
const idSemaines = `cadence-semaines-${site}`;
|
||||
const idListe = `cadence-liste-${site}`;
|
||||
const idErreur = `cadence-erreur-${site}`;
|
||||
|
||||
// label affiché dans le datalist → fournisseur
|
||||
const byLabel = useMemo(() => {
|
||||
const m = new Map<string, { code: string; nom: string }>();
|
||||
// Libellé affiché dans la liste de suggestions → fournisseur
|
||||
const parLibelle = useMemo(() => {
|
||||
const m = new Map<string, Fournisseur>();
|
||||
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
|
||||
return m;
|
||||
}, [fournisseurs]);
|
||||
|
||||
function submit() {
|
||||
setErr(null);
|
||||
const f = byLabel.get(value.trim());
|
||||
async function ajouter(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErreur(null);
|
||||
const f = parLibelle.get(saisie.trim());
|
||||
if (!f) {
|
||||
setErr("Sélectionnez un fournisseur dans la liste.");
|
||||
setErreur("Choisissez un fournisseur dans la liste proposée pendant la saisie.");
|
||||
return;
|
||||
}
|
||||
if (existingCodes.has(f.code)) {
|
||||
setErr("Ce fournisseur est déjà cadencé sur ce magasin.");
|
||||
if (codesExistants.has(f.code)) {
|
||||
setErreur("Ce fournisseur a déjà un rappel dans ce magasin : modifiez son rythme dans le tableau ci-dessous.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) {
|
||||
setErr("Intervalle : 1 à 104 semaines.");
|
||||
const n = Number(semaines);
|
||||
if (!Number.isInteger(n) || n < SEMAINES_MIN || n > SEMAINES_MAX) {
|
||||
setErreur(`Indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`);
|
||||
return;
|
||||
}
|
||||
onDone({ codefou: f.code, nomfou: f.nom, semaines });
|
||||
setValue("");
|
||||
setSemaines(4);
|
||||
// Le formulaire n'est vidé qu'une fois le rappel réellement enregistré.
|
||||
if (await onAjouter({ codefou: f.code, nomfou: f.nom, semaines: n })) {
|
||||
setSaisie("");
|
||||
setSemaines("4");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
|
||||
<input
|
||||
list={listId}
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f.code} value={`${f.nom} — ${f.code}`} />
|
||||
))}
|
||||
</datalist>
|
||||
<form
|
||||
onSubmit={ajouter}
|
||||
className="rounded-xl border border-dashed border-[var(--border-strong)] bg-[var(--bg-surface)] p-4"
|
||||
>
|
||||
<p className="mb-3 text-sm font-medium text-[var(--text-primary)]">Ajouter un rappel pour ce magasin</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label htmlFor={idFournisseur}>Fournisseur</Label>
|
||||
<Input
|
||||
id={idFournisseur}
|
||||
list={idListe}
|
||||
value={saisie}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<datalist id={idListe}>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f.code} value={`${f.nom} — ${f.code}`} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={idSemaines}>Toutes les … semaines</Label>
|
||||
<Input
|
||||
id={idSemaines}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={SEMAINES_MIN}
|
||||
max={SEMAINES_MAX}
|
||||
step={1}
|
||||
value={semaines}
|
||||
onChange={(e) => setSemaines(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending}>
|
||||
<Plus /> Ajouter le rappel
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={104}
|
||||
value={semaines}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Activer l'alerte
|
||||
</button>
|
||||
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
|
||||
</div>
|
||||
{erreur && (
|
||||
<p id={idErreur} role="alert" className="mt-2 text-[13px] font-medium text-[var(--accent-error)]">
|
||||
{erreur}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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 router = useRouter();
|
||||
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;
|
||||
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
startTransition(async () => {
|
||||
await fn();
|
||||
router.refresh();
|
||||
/**
|
||||
* 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<Resultat>, messages: { succes: string; echec: string }): Promise<boolean> {
|
||||
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 (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
||||
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<section className="space-y-3" aria-label={`Rythme de commande — ${nom}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
|
||||
<Badge>
|
||||
{pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{nbACommander > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
|
||||
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
|
||||
</span>
|
||||
<Badge ton="erreur">
|
||||
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {nbACommander} à commander
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddCadenceForm
|
||||
<FormulaireAjout
|
||||
site={site}
|
||||
fournisseurs={fournisseurs}
|
||||
existingCodes={existingCodes}
|
||||
onDone={add}
|
||||
codesExistants={codesExistants}
|
||||
onAjouter={ajouter}
|
||||
pending={pending}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
{triees.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarClock}
|
||||
className="py-8"
|
||||
title="Aucun rappel pour ce magasin"
|
||||
description="Ajoutez un fournisseur avec le formulaire ci-dessus pour être prévenu quand il faut lui passer commande."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-busy={pending}
|
||||
className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||
>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-[var(--bg-elevated)]">
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
|
||||
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
|
||||
</td>
|
||||
<th scope="col" className={TH}>Fournisseur</th>
|
||||
<th scope="col" className={TH}>Rythme</th>
|
||||
<th scope="col" className={TH}>Dernière réception</th>
|
||||
<th scope="col" className={TH}>
|
||||
<InfoBulle explication="Date de la dernière réception du fournisseur dans ce magasin, plus le rythme choisi.">
|
||||
Prochaine commande
|
||||
</InfoBulle>
|
||||
</th>
|
||||
<th scope="col" className={TH}>Temps restant</th>
|
||||
<th scope="col" className={TH}>Statut</th>
|
||||
<th scope="col" className={cn(TH, "text-right")}>Actions</th>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map((r) => {
|
||||
const urgent = r.actif && r.statut === "a_commander";
|
||||
return (
|
||||
<tr
|
||||
key={`${r.site}-${r.codefou}`}
|
||||
className={[
|
||||
"transition-colors",
|
||||
!r.actif ? "opacity-50" : "",
|
||||
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
|
||||
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={104}
|
||||
defaultValue={r.intervalleSemaines}
|
||||
onBlur={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">sem.</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
|
||||
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
|
||||
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
|
||||
{tempsRestant(r.joursRestants)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title={r.actif ? "Désactiver" : "Réactiver"}
|
||||
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
|
||||
disabled={pending}
|
||||
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Supprimer"
|
||||
onClick={() => run(() => removeCadence(r.codefou, r.site))}
|
||||
disabled={pending}
|
||||
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{triees.map((r) => {
|
||||
const urgent = r.actif && r.statut === "a_commander";
|
||||
const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]";
|
||||
return (
|
||||
<tr
|
||||
key={`${r.site}-${r.codefou}`}
|
||||
className={cn(
|
||||
"border-b border-[var(--border)] last:border-b-0",
|
||||
urgent ? "bg-[var(--accent-error-bg)]" : "hover:bg-[var(--bg-elevated)]",
|
||||
)}
|
||||
>
|
||||
<td className={cn("px-3 py-2", texte)}>
|
||||
<div className="font-medium">{r.nomfou}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>
|
||||
</td>
|
||||
<td className={cn("whitespace-nowrap px-3 py-2", texte)}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Toutes les
|
||||
<Input
|
||||
// Remonté quand la valeur enregistrée change (après revalidation).
|
||||
key={r.intervalleSemaines}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={SEMAINES_MIN}
|
||||
max={SEMAINES_MAX}
|
||||
step={1}
|
||||
defaultValue={r.intervalleSemaines}
|
||||
aria-label={`Rythme de commande de ${r.nomfou}, en semaines`}
|
||||
title="Modifiez le nombre puis quittez le champ (ou appuyez sur Entrée) pour l'enregistrer."
|
||||
onKeyDown={(e) => {
|
||||
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
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn("whitespace-nowrap px-3 py-2 tabular-nums", r.actif ? "text-[var(--text-secondary)]" : texte)}>
|
||||
{fmtDate(r.derniereReception)}
|
||||
</td>
|
||||
<td className={cn("whitespace-nowrap px-3 py-2 font-medium tabular-nums", texte)}>
|
||||
{fmtDate(r.echeance)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"whitespace-nowrap px-3 py-2 font-semibold",
|
||||
urgent ? "text-[var(--accent-error)]" : texte,
|
||||
)}
|
||||
>
|
||||
{tempsRestant(r.joursRestants)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatutBadge cadence={r} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Tooltip
|
||||
content={
|
||||
r.actif
|
||||
? "Mettre les alertes en pause, sans perdre le rythme choisi."
|
||||
: "Reprendre les alertes pour ce fournisseur."
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void basculer(r)}
|
||||
disabled={pending}
|
||||
aria-label={`${r.actif ? "Désactiver" : "Réactiver"} le rappel de ${r.nomfou}`}
|
||||
>
|
||||
<Power /> {r.actif ? "Désactiver" : "Réactiver"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Supprimer définitivement ce rappel.">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void supprimer(r)}
|
||||
disabled={pending}
|
||||
aria-label={`Supprimer le rappel de ${r.nomfou}`}
|
||||
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
|
||||
>
|
||||
<Trash2 /> Supprimer
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -316,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<string, CadenceView[]>();
|
||||
for (const c of cadences) {
|
||||
if (!m.has(c.site)) m.set(c.site, []);
|
||||
@@ -329,19 +472,17 @@ export function CadencierClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||
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{" "}
|
||||
<strong>dernière réception</strong> du fournisseur sur le magasin.
|
||||
</p>
|
||||
{SITES.map(({ code, label }) => (
|
||||
<SiteSection
|
||||
key={code}
|
||||
site={code}
|
||||
label={label}
|
||||
rows={bySite.get(code) ?? []}
|
||||
fournisseurs={fournisseurs}
|
||||
/>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] px-4 py-3 text-sm text-[var(--text-primary)]">
|
||||
<CalendarClock className="mt-0.5 h-5 w-5 shrink-0 text-[var(--accent)]" aria-hidden />
|
||||
<p>
|
||||
Le <Terme id="cadencier">cadencier</Terme> vous rappelle quand commander : pour chaque fournisseur suivi,
|
||||
choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "}
|
||||
<strong>dernière réception</strong> du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les
|
||||
7 jours qui précèdent, puis à « À commander » le jour venu.
|
||||
</p>
|
||||
</div>
|
||||
{MAGASINS.map((m) => (
|
||||
<SectionMagasin key={m.code} site={m.code} rows={parMagasin.get(m.code) ?? []} fournisseurs={fournisseurs} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<EtatFranco, { libelle: string; ton: Ton }> = {
|
||||
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 <span className="font-semibold text-[var(--accent-warning)]">Il manque {fmtEur2(ecart)}</span>;
|
||||
}
|
||||
if (etat === "oui") {
|
||||
return <span className="text-[var(--accent-success)]">{ecart > 0 ? `Dépassé de ${fmtEur2(ecart)}` : "Atteint"}</span>;
|
||||
}
|
||||
return <span className="text-[var(--text-muted)]">—</span>;
|
||||
}
|
||||
|
||||
const somme = (rows: readonly PgCommandeAutoRow[], valeur: (r: PgCommandeAutoRow) => number) =>
|
||||
rows.reduce((s, r) => s + (Number(valeur(r)) || 0), 0);
|
||||
|
||||
const COLONNES: DataColumn<PgCommandeAutoRow>[] = [
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: (r) => r.nomfou,
|
||||
cell: (r) => (
|
||||
<div className="min-w-[200px]">
|
||||
<div className="font-medium text-[var(--text-primary)]">{r.nomfou || "Fournisseur sans nom"}</div>
|
||||
{r.codefou && <div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>}
|
||||
</div>
|
||||
),
|
||||
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) => <span className="font-semibold">{fmtEur2(Number(r.montant_cde))}</span>,
|
||||
footer: (rows) => fmtEur2(somme(rows, (r) => r.montant_cde)),
|
||||
},
|
||||
{
|
||||
id: "franco",
|
||||
header: <Terme id="franco" />,
|
||||
align: "right",
|
||||
sortValue: (r) => (Number(r.franco) > 0 ? Number(r.franco) : null),
|
||||
cell: (r) =>
|
||||
Number(r.franco) > 0 ? (
|
||||
fmtEur2(Number(r.franco))
|
||||
) : (
|
||||
<span className="text-[var(--text-muted)]" title="Aucun montant de franco connu pour ce fournisseur.">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Badge ton={ton} title={explicationFranco(r)}>{libelle}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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) => <EcartFranco r={r} />,
|
||||
},
|
||||
];
|
||||
|
||||
function fmt(n: number) {
|
||||
return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||
}
|
||||
const FILTRES: DataFilter<PgCommandeAutoRow>[] = [
|
||||
{
|
||||
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 <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
||||
<Minus className="w-3 h-3" /> Pas de franco
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (atteint === null) {
|
||||
// Franco connu mais statut non calculé par l'API
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
||||
<Minus className="w-3 h-3" /> N/A ({fmt(franco)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (atteint) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
<CheckCircle2 className="w-3 h-3" /> Atteint
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
|
||||
<AlertCircle className="w-3 h-3" /> −{fmt(Math.abs(ecart))} manquant
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) {
|
||||
const [sortKey, setSortKey] = useState<SK>("nomfou");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("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) => (
|
||||
<th
|
||||
className={[
|
||||
"cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900",
|
||||
right ? "text-right" : "text-left",
|
||||
].join(" ")}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{/* En-tête magasin */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
||||
{rows.length} fournisseur{rows.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{nbFrancoAtteint > 0 && (
|
||||
<span className="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
{nbFrancoAtteint} franco atteint
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">
|
||||
Total : <span className="font-semibold text-gray-800">{fmt(totalMontant)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un fournisseur…"
|
||||
value={search}
|
||||
onChange={e => 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]"
|
||||
/>
|
||||
<section className="space-y-3" aria-label={`Propositions de commande — ${nom}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
|
||||
<Badge>
|
||||
{fmtEntier(rows.length)} fournisseur{rows.length > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{nbAtteint > 0 && (
|
||||
<Badge ton="succes">
|
||||
{fmtEntier(nbAtteint)} franco{nbAtteint > 1 ? "s" : ""} atteint{nbAtteint > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[13px] text-[var(--text-secondary)]">
|
||||
Montant total proposé :{" "}
|
||||
<span className="font-semibold tabular-nums text-[var(--text-primary)]">{fmtEur2(totalMontant)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codefou", "Code")}
|
||||
{th("nomfou", "Fournisseur")}
|
||||
{th("nb_articles", "Articles", true)}
|
||||
{th("montant_cde", "Montant CDE auto", true)}
|
||||
{th("franco", "Franco", true)}
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
||||
Statut franco
|
||||
</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
|
||||
Aucun fournisseur en commande automatique
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map(row => {
|
||||
const francoAtteint = row.franco_atteint === true;
|
||||
const hasFranco = row.franco > 0 && row.franco_atteint !== null;
|
||||
return (
|
||||
<tr
|
||||
key={`${row.site}-${row.codefou}`}
|
||||
className={[
|
||||
"transition-colors",
|
||||
francoAtteint
|
||||
? "bg-emerald-50/50 hover:bg-emerald-50"
|
||||
: "hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{row.codefou}</td>
|
||||
<td className="px-3 py-2.5 font-medium text-gray-900">{row.nomfou}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700">{row.nb_articles}</td>
|
||||
<td className={[
|
||||
"px-3 py-2.5 text-right tabular-nums font-semibold",
|
||||
francoAtteint ? "text-emerald-700" : "text-blue-700",
|
||||
].join(" ")}>
|
||||
{fmt(Number(row.montant_cde))}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-600">
|
||||
{row.franco > 0 ? fmt(Number(row.franco)) : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<FrancoCell
|
||||
atteint={row.franco_atteint}
|
||||
ecart={Number(row.ecart_franco)}
|
||||
franco={Number(row.franco)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={COLONNES}
|
||||
rowKey={(r) => `${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."
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
|
||||
const bySite = useMemo(() => {
|
||||
const parMagasin = useMemo(() => {
|
||||
const map = new Map<string, PgCommandeAutoRow[]>();
|
||||
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 (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-12 text-center text-gray-400 shadow-sm">
|
||||
Aucune commande automatique trouvée.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={ShoppingCart}
|
||||
title="Aucune proposition de commande"
|
||||
description="FF ne propose aucune commande automatique pour le moment. Si vous en attendiez, le serveur FF est peut-être momentanément indisponible : rechargez la page dans quelques minutes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{SITES.map(({ code, label }) => {
|
||||
const siteRows = bySite.get(code) ?? [];
|
||||
return (
|
||||
<StoreTable key={code} site={code} label={label} rows={siteRows} />
|
||||
);
|
||||
})}
|
||||
<div className="space-y-10">
|
||||
{MAGASINS.map((m) => (
|
||||
<SectionMagasin key={m.code} nom={nomMagasin(m.code)} rows={parMagasin.get(m.code) ?? []} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function CommandesAutoLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des commandes automatiques..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={8} />;
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
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([
|
||||
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(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
|
||||
<CommandesAutoTabs rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={ShoppingCart}
|
||||
title="Commandes fournisseurs"
|
||||
description="Propositions de commande automatique par magasin, et rappels selon le rythme de commande choisi pour chaque fournisseur."
|
||||
/>
|
||||
<CommandesAutoTabs initial={ongletInitial} rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TabKey>("propositions");
|
||||
const [onglet, setOnglet] = useUrlTab<Onglet>("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) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab(key)}
|
||||
className={[
|
||||
"inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors",
|
||||
tab === key
|
||||
? "border-blue-600 text-blue-700"
|
||||
: "border-transparent text-gray-500 hover:text-gray-800",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
{badge != null && badge > 0 && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
const onglets = [
|
||||
{ value: "propositions", label: "Propositions de commande", icon: ShoppingCart },
|
||||
{
|
||||
value: "cadencier",
|
||||
label: (
|
||||
<>
|
||||
Rythme de commande
|
||||
{nbACommander > 0 && <span className="sr-only"> (commandes à passer)</span>}
|
||||
</>
|
||||
),
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{tabBtn("propositions", "Propositions", ShoppingCart)}
|
||||
{tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)}
|
||||
</div>
|
||||
<Tabs items={onglets} value={onglet} onChange={setOnglet} />
|
||||
|
||||
{tab === "propositions" ? (
|
||||
{onglet === "propositions" ? (
|
||||
<CommandesAutoClient rows={rows} />
|
||||
) : (
|
||||
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération du tableau de bord..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton cards={4} rows={6} />;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-3xl py-10">
|
||||
<ErrorState
|
||||
title="Cette page n'a pas pu s'afficher"
|
||||
description="Un problème est survenu pendant le chargement des données. Réessayez ; si l'erreur revient, prévenez un administrateur."
|
||||
detail={error.digest ? `${error.message}\nRéférence : ${error.digest}` : error.message}
|
||||
action={<Button onClick={reset}><RotateCcw /> Réessayer</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function ExportsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des exports..." />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<FileDown className="w-5 h-5 text-[var(--accent)]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-[var(--text-primary)] uppercase">Historique d'Arbitrage</h1>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-xl">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnapshotList type="export" />
|
||||
</div>
|
||||
);
|
||||
redirect("/historique?vue=exports");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function GridLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des articles et calcul des scores..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={12} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -19,14 +23,17 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
const filters = {
|
||||
code1: params.code1 || null,
|
||||
code2: params.code2 || null,
|
||||
code3: params.code3 || null,
|
||||
// Liste séparée par des virgules : le filtre accepte plusieurs nomenclatures.
|
||||
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
|
||||
};
|
||||
|
||||
// 1. Fetch available suppliers & stores
|
||||
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) {
|
||||
@@ -43,6 +50,7 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
magasins={magasins}
|
||||
magasin={magasin}
|
||||
filters={filters}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<PageHeader
|
||||
icon={History}
|
||||
title="Historique"
|
||||
description="Vos sessions de révision enregistrées et vos exports Excel. Rouvrez une session pour reprendre les modifications de gammes là où vous les aviez laissées."
|
||||
/>
|
||||
<HistoriqueTabs initial={vue === "exports" ? "exports" : "sessions"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>("vue", initial);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs items={ONGLETS} value={vue} onChange={setVue} />
|
||||
{/* `key` : chaque onglet recharge sa propre liste. */}
|
||||
<SnapshotList key={vue} type={vue === "exports" ? "export" : "snapshot"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,380 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
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<string, Pick<Groupe, "qte" | "ca" | "marge" | "stock">> = {
|
||||
"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 = <span className="text-[var(--text-muted)]">—</span>;
|
||||
|
||||
function CelluleMarge({ taux }: { taux: number | null }) {
|
||||
if (taux === null) return vide;
|
||||
return <span className="font-medium" style={{ color: couleurMarge(taux) }}>{fmtDecimal1(taux)} %</span>;
|
||||
}
|
||||
function formatPct(ca: number, marge: number) {
|
||||
if (ca === 0) return "—";
|
||||
return ((marge / ca) * 100).toFixed(1) + " %";
|
||||
|
||||
function CelluleStock({ v }: { v: number }) {
|
||||
return (
|
||||
<span className={cn(v < 0 ? "font-semibold text-[var(--accent-error)]" : v === 0 ? "text-[var(--text-muted)]" : undefined)}>
|
||||
{fmtEntier(v)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
|
||||
const total = g.id === "total";
|
||||
const bord = "border-l border-[var(--border)]";
|
||||
return [
|
||||
{
|
||||
id: `${g.id}-qte`,
|
||||
header: <EnTete groupe={g.titre}>Qté</EnTete>,
|
||||
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: <EnTete groupe={g.titre}>CA TTC</EnTete>,
|
||||
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: <EnTete groupe={g.titre}>% marge</EnTete>,
|
||||
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 => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
|
||||
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: `${g.id}-stock`,
|
||||
header: <EnTete groupe={g.titre}>Stock</EnTete>,
|
||||
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
|
||||
align: "right",
|
||||
sortValue: r => r[g.stock],
|
||||
cell: r => <CelluleStock v={r[g.stock]} />,
|
||||
footer: rows => fmtEntier(somme(rows, g.stock)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const COLONNES: DataColumn<Ligne>[] = [
|
||||
{
|
||||
id: "codein",
|
||||
header: "Code",
|
||||
sortValue: r => r.codein,
|
||||
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
||||
footer: () => "Total",
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: "libelle",
|
||||
header: "Désignation",
|
||||
sortValue: r => r.libelle.trim(),
|
||||
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
|
||||
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
|
||||
className: "min-w-[220px]",
|
||||
},
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: r => r.fournisseur,
|
||||
cell: r => <span className="text-[13px] text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: "famille",
|
||||
header: "Famille",
|
||||
hint: "Famille de produits (nomenclature FF)",
|
||||
sortValue: famille,
|
||||
cell: r =>
|
||||
r.nomenclature_code ? (
|
||||
<span className="text-[13px] text-[var(--text-secondary)]" title={famille(r)}>
|
||||
<span className="font-mono text-[var(--text-muted)]">{r.nomenclature_code}</span> {r.nomenclature}
|
||||
</span>
|
||||
) : vide,
|
||||
className: "min-w-[160px]",
|
||||
},
|
||||
...GROUPES.flatMap(colonnesGroupe),
|
||||
];
|
||||
|
||||
const FILTRES: DataFilter<Ligne>[] = [
|
||||
{ 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 (
|
||||
<th
|
||||
onClick={sortable ? () => 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 && (
|
||||
<span className="ml-1 inline-block w-3 text-center">
|
||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
const router = useRouter();
|
||||
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR);
|
||||
const [filterFournisseur, setFilterFournisseur] = useState<string>("Tous");
|
||||
const [filterNomenclature, setFilterNomenclature] = useState<string>("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<string, string>(); // 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]);
|
||||
|
||||
function exportToExcel() {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
{/* Période */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Du</label>
|
||||
<input
|
||||
type="date"
|
||||
value={localDebut}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Au</label>
|
||||
<input
|
||||
type="date"
|
||||
value={localFin}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={applyDates}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
<div className="space-y-4" aria-busy={navigation}>
|
||||
{/* Période (relance le calcul côté serveur) */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label htmlFor="hit-parade-debut">Du</Label>
|
||||
<Input
|
||||
id="hit-parade-debut"
|
||||
type="date"
|
||||
value={localDebut}
|
||||
onChange={e => setLocalDebut(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
{/* Filtre fournisseur */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Fournisseur</label>
|
||||
<select
|
||||
value={filterFournisseur}
|
||||
onChange={e => setFilterFournisseur(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 min-w-[200px]"
|
||||
>
|
||||
{fournisseurs.map(f => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<Label htmlFor="hit-parade-fin">Au</Label>
|
||||
<Input
|
||||
id="hit-parade-fin"
|
||||
type="date"
|
||||
value={localFin}
|
||||
onChange={e => setLocalFin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
{/* Filtre nomenclature */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Nomenclature</label>
|
||||
<select
|
||||
value={filterNomenclature}
|
||||
onChange={e => setFilterNomenclature(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 min-w-[200px]"
|
||||
>
|
||||
{nomenclatures.map(n => (
|
||||
<option key={n.code} value={n.code}>
|
||||
{n.code === "Tous" ? "Tous" : `${n.code} — ${n.libelle}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{sorted.length} produits
|
||||
</span>
|
||||
|
||||
{isSorted && (
|
||||
<button
|
||||
onClick={resetSort}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-sm font-medium text-orange-700 hover:bg-orange-100 transition-colors self-center"
|
||||
>
|
||||
<span>✕</span> Annuler le tri
|
||||
</button>
|
||||
<Button onClick={applyDates} disabled={navigation || !localDebut || !localFin || periodeInvalide}>
|
||||
{navigation && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{navigation ? "Chargement…" : "Appliquer"}
|
||||
</Button>
|
||||
{periodeInvalide && (
|
||||
<p role="alert" className="flex h-9 items-center text-[13px] text-[var(--accent-error)]">
|
||||
La date de début doit précéder la date de fin.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="ml-auto flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition-colors self-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} className="w-20 border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Fournisseur</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Nom.</th>
|
||||
<th colSpan={4} className="border-b border-l-2 border-blue-200 bg-blue-50 px-4 py-2 text-center text-xs font-bold text-blue-700 tracking-wide">
|
||||
Frouard / Nancy — 292
|
||||
</th>
|
||||
<th colSpan={4} className="border-b border-l-2 border-violet-200 bg-violet-50 px-4 py-2 text-center text-xs font-bold text-violet-700 tracking-wide">
|
||||
Houdemont — 579
|
||||
</th>
|
||||
<th colSpan={4} className="border-b border-l-2 border-emerald-200 bg-emerald-50 px-4 py-2 text-center text-xs font-bold text-emerald-700 tracking-wide">
|
||||
Total Réseau
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
|
||||
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-xs text-center whitespace-nowrap" title={row.nomenclature_code ? `${row.nomenclature_code} — ${row.nomenclature}` : ""}>
|
||||
{row.nomenclature_code
|
||||
? <span className="font-mono font-semibold text-gray-700">{row.nomenclature_code}</span>
|
||||
: <span className="text-gray-300">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className={`border-l-2 border-blue-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
||||
{row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
||||
{row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="bg-blue-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-blue-500">
|
||||
{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock292" ? "bg-blue-100 font-semibold text-blue-700" : "bg-blue-50/60 text-amber-600"}`}>
|
||||
{row.stock292 > 0 ? formatQte(row.stock292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`border-l-2 border-violet-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
||||
{row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
||||
{row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="bg-violet-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-violet-500">
|
||||
{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock579" ? "bg-violet-100 font-semibold text-violet-700" : "bg-violet-50/60 text-amber-600"}`}>
|
||||
{row.stock579 > 0 ? formatQte(row.stock579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`border-l-2 border-emerald-200 px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
||||
{row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
||||
{row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="bg-emerald-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-emerald-600">
|
||||
{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs font-semibold ${sortKey === "stockTotal" ? "bg-emerald-100 text-emerald-700" : row.stockTotal <= 0 ? "bg-emerald-50/60 text-red-400" : "bg-emerald-50/60 text-amber-600"}`}>
|
||||
{row.stockTotal > 0 ? formatQte(row.stockTotal) : <span className="text-red-400 font-semibold">0</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300">
|
||||
<td colSpan={4} className="bg-gray-100 px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {sorted.length} articles
|
||||
</td>
|
||||
<td className="border-l-2 border-blue-300 bg-blue-100 px-4 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs tabular-nums text-blue-700 font-semibold">{formatPct(totals.ca292, totals.marge292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock292)}</td>
|
||||
<td className="border-l-2 border-violet-300 bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs tabular-nums text-violet-700 font-semibold">{formatPct(totals.ca579, totals.marge579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock579)}</td>
|
||||
<td className="border-l-2 border-emerald-300 bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qteTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.caTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs tabular-nums text-emerald-800 font-semibold">{formatPct(totals.caTotal, totals.margeTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stockTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
|
||||
<DataTable<Ligne>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function HitParadeLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération du classement des ventes..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={12} />;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
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 { 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;
|
||||
libelle: string;
|
||||
@@ -80,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 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 (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1>
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={Trophy}
|
||||
title="Meilleures ventes"
|
||||
description="Le classement des produits les plus vendus sur une période, magasin par magasin : quantités, chiffre d'affaires, taux de marge et stock actuel."
|
||||
/>
|
||||
{pivotted ? (
|
||||
<HitParadeClient
|
||||
dateDebut={dateDebut}
|
||||
dateFin={dateFin}
|
||||
pivotted={pivotted}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorState
|
||||
title="Les meilleures ventes n'ont pas pu être chargées"
|
||||
detail={erreur ?? undefined}
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/hit-parade?debut=${dateDebut}&fin=${dateFin}`}><RotateCcw /> Réessayer</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col h-full min-w-0 overflow-hidden">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-y-auto flex flex-col p-4 md:p-8">
|
||||
<div className="flex-1 overflow-y-auto flex flex-col p-4 md:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-3xl py-10">
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="Page introuvable"
|
||||
description="L'adresse demandée n'existe pas ou n'existe plus. Utilisez le menu à gauche pour retrouver votre page."
|
||||
action={<Button asChild><Link href="/dashboard">Retour à l'accueil</Link></Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function ProduitsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={6} />;
|
||||
}
|
||||
@@ -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<PgOpportuniteRow>[] = [
|
||||
{
|
||||
id: "produit",
|
||||
header: "Produit",
|
||||
sortValue: (r) => r.libelle1 || r.codein,
|
||||
cell: (r) => (
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||
className="font-medium text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{r.libelle1 || r.codein}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: (r) => r.fournisseur,
|
||||
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: opportunite ? "var(--accent-warning)" : "var(--accent-success)" }}
|
||||
title={opportunite
|
||||
? "Le réseau vend plus que nous sur ce produit"
|
||||
: "Nous vendons autant ou plus que le réseau"}
|
||||
>
|
||||
{opportunite ? "+" : ""}{fmtDecimal1(r.ecart_par_magasin)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "magasins",
|
||||
header: "Magasins vendeurs",
|
||||
hint: GLOSSAIRE.presenceReseau.definition,
|
||||
align: "right",
|
||||
sortValue: (r) => r.nb_magasins_reseau,
|
||||
cell: (r) => <span className="text-[var(--text-secondary)]">{fmtEntier(r.nb_magasins_reseau)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<ErrorState
|
||||
className="py-8"
|
||||
title="L'analyse de la famille n'a pas pu être faite"
|
||||
description="Réessayez dans un instant. Si le problème persiste, prévenez un administrateur."
|
||||
detail={error}
|
||||
action={
|
||||
<Button variant="outline" onClick={load}>
|
||||
<RefreshCw /> Réessayer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
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 ?
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
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 ?
|
||||
</p>
|
||||
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
|
||||
{loading ? "Analyse…" : "Analyser la famille"}
|
||||
</button>
|
||||
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
|
||||
<Button variant="outline" onClick={load} disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <TrendingUp />}
|
||||
{loading ? "Analyse en cours…" : "Analyser la famille"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,71 +150,31 @@ export function OpportunitesFamille({
|
||||
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
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.
|
||||
</p>
|
||||
<EmptyState
|
||||
className="py-8"
|
||||
title="Aucun autre produit à comparer"
|
||||
description="Aucun autre produit de cette famille n'a encore de données du réseau. Mettez à jour les données du réseau d'autres fournisseurs (révision d'assortiment) pour enrichir la comparaison."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{others.map((r) => {
|
||||
const opportunite = r.ecart_par_magasin > 0;
|
||||
return (
|
||||
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || r.codein}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_reseau_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_locale_par_magasin)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-semibold"
|
||||
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
|
||||
title={opportunite
|
||||
? "Le réseau vend plus que nous sur ce produit"
|
||||
: "Nous vendons autant ou plus que le réseau"}
|
||||
>
|
||||
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtQte(r.nb_magasins_reseau)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
É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 <strong>de notre catalogue</strong> 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.
|
||||
<div className="space-y-3">
|
||||
<DataTable
|
||||
rows={others}
|
||||
columns={COLONNES}
|
||||
rowKey={(r) => r.codein}
|
||||
searchIn={rechercherDans}
|
||||
searchPlaceholder="Filtrer les produits…"
|
||||
pageSize={25}
|
||||
unite={others.length > 1 ? "produits" : "produit"}
|
||||
maxHeight="60vh"
|
||||
/>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
É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 <strong>de notre
|
||||
catalogue</strong> qui ont déjà des données du réseau apparaissent ici : un produit absent de notre
|
||||
catalogue ne peut pas y figurer.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
||||
Recherche produit
|
||||
</h1>
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
|
||||
performance réseau et ventes locales sur 12 mois glissants.
|
||||
</p>
|
||||
</header>
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={PackageSearch}
|
||||
title="Recherche produit"
|
||||
description={
|
||||
<>
|
||||
Cherchez un produit par son nom ou son code, puis comparez ses ventes à celles des
|
||||
magasins du <Terme id="reseau">réseau.</Terme>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||
<ProduitSearchBar key={q} initialQuery={q} />
|
||||
<div className="space-y-6">
|
||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||
<ProduitSearchBar key={q} initialQuery={q} />
|
||||
|
||||
{ficheDemandee && !fiche && (
|
||||
<div
|
||||
className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun produit trouvé pour{" "}
|
||||
{codein
|
||||
? <>le code article <span className="font-mono">{codein}</span></>
|
||||
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
|
||||
{codeCentrale && !codein && (
|
||||
<div className="mt-1">
|
||||
Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ficheDemandee && !fiche && (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="Produit introuvable"
|
||||
description={
|
||||
codein ? (
|
||||
<>Aucun produit de notre catalogue ne porte le code article <span className="font-mono">{codein}</span>.</>
|
||||
) : (
|
||||
<>
|
||||
Le code centrale <span className="font-mono">{codeCentrale}</span> 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 && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||
|
||||
{!ficheDemandee && <ProduitResults query={q} />}
|
||||
{!ficheDemandee && <ProduitResults query={q} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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 (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2.5 rounded-xl border border-[var(--accent-warning)]/40 bg-[var(--accent-warning-bg)] px-4 py-3 text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-warning)]" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
{children}
|
||||
{detail && (
|
||||
<details className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
<summary className="cursor-pointer">Détail technique</summary>
|
||||
<p className="mt-1 break-words">{detail}</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultats de recherche produit.
|
||||
*
|
||||
@@ -144,36 +176,31 @@ export function ProduitResults({ query }: { query: string }) {
|
||||
|
||||
if (!query) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Recherchez un produit dans le réseau
|
||||
</p>
|
||||
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
|
||||
La recherche interroge <strong>Qlik Sense</strong> (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 »).
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={PackageSearch}
|
||||
title="Recherchez un produit"
|
||||
description={
|
||||
<>
|
||||
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 (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
aria-busy="true"
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] px-6 py-12 text-center"
|
||||
>
|
||||
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
{etape ?? "Interrogation de Qlik Sense…"}
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
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.
|
||||
<Loader2 className="h-7 w-7 animate-spin text-[var(--accent)]" aria-hidden />
|
||||
<p className="text-base font-semibold text-[var(--text-primary)]">{libelleEtape(etape)}</p>
|
||||
<p className="max-w-lg text-sm text-[var(--text-secondary)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -181,21 +208,20 @@ export function ProduitResults({ query }: { query: string }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
La recherche a échoué
|
||||
</p>
|
||||
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
title="La recherche a échoué"
|
||||
description={
|
||||
mentionneOutil(error)
|
||||
? "Les données du réseau n'ont pas répondu. Réessayez dans un instant ; si la recherche est très large, ajoutez un mot plus précis."
|
||||
: error
|
||||
}
|
||||
detail={mentionneOutil(error) ? error : undefined}
|
||||
action={
|
||||
<Button variant="outline" onClick={relancer}>
|
||||
<RefreshCw /> Réessayer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,196 +230,227 @@ export function ProduitResults({ query }: { query: string }) {
|
||||
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
{source === "qlik" ? (
|
||||
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{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
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
{source === "qlik"
|
||||
? <Globe className="h-4 w-4 text-[var(--accent)]" aria-hidden />
|
||||
: <Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
|
||||
<span>
|
||||
<span className="font-semibold text-[var(--text-primary)]">{fmtEntier(rows.length)}</span>{" "}
|
||||
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)"}
|
||||
<span className="text-[var(--text-muted)]"> · recherche en {fmtDecimal1(dureeMs / 1000)} s</span>
|
||||
</span>
|
||||
</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Relancer
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={relancer}
|
||||
title="Interroger à nouveau les données du réseau, sans réutiliser le dernier résultat"
|
||||
>
|
||||
<RefreshCw /> Relancer la recherche
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{source === "db" && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
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 && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Avertissement detail={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.
|
||||
</Avertissement>
|
||||
)}
|
||||
|
||||
{source === "qlik" && qlikError && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
{rows.length === 0
|
||||
? qlikError
|
||||
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
|
||||
</span>
|
||||
</div>
|
||||
<Avertissement detail={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."}
|
||||
</Avertissement>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun article du réseau ne correspond à « {query} ».
|
||||
</div>
|
||||
<EmptyState
|
||||
title={<>Aucun produit ne correspond à « {query} »</>}
|
||||
description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit."
|
||||
/>
|
||||
) : (
|
||||
<ResultTable rows={rows} query={query} />
|
||||
<>
|
||||
<ResultTable rows={rows} query={query} />
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Chiffres des magasins du <Terme id="reseau">réseau</Terme> sur les 12 derniers mois
|
||||
(mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{locauxHorsReseau.length > 0 && (
|
||||
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{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)
|
||||
<details className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<summary className="cursor-pointer px-4 py-3 text-sm text-[var(--text-secondary)]">
|
||||
{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)
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<ul className="space-y-1">
|
||||
{locauxHorsReseau.slice(0, 30).map((l) => (
|
||||
<li key={l.codein} className="text-[12px]">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{l.libelle1 || l.codein}
|
||||
</Link>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="space-y-1.5 px-4 pb-4">
|
||||
{locauxHorsReseau.slice(0, 30).map((l) => (
|
||||
<li key={l.codein} className="text-sm">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="font-medium text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{l.libelle1 || l.codein}
|
||||
</Link>
|
||||
<span className="text-[var(--text-muted)]">
|
||||
{" · "}{l.fournisseur}{" · "}code article <span className="font-mono">{l.codein}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<Th>Libellé</Th>
|
||||
<Th>Fournisseur</Th>
|
||||
<Th>Code centrale</Th>
|
||||
<Th align="right">Magasins</Th>
|
||||
<Th align="right">Qté réseau</Th>
|
||||
<Th align="right">Qté / mag.</Th>
|
||||
<Th align="right">Prix moyen</Th>
|
||||
<Th align="right">CA / mag.</Th>
|
||||
<Th align="right">Marge %</Th>
|
||||
<Th align="center">Tendance</Th>
|
||||
<Th align="right">Nous</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[280px]">
|
||||
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
|
||||
{r.libelle || "—"}
|
||||
</Link>
|
||||
{r.nomenclature && (
|
||||
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.fournisseur || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.codeCentrale}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtQte(r.nbMagasinsReseau)}
|
||||
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtDec(r.tauxPresence * 100, 0)} %
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{fmtQte(r.qteReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qteParMagasinReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[12px]">
|
||||
{r.codein ? (
|
||||
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
|
||||
stock {fmtQte(r.stockLocal ?? 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-md px-1.5 py-0.5 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
|
||||
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
|
||||
>
|
||||
non référencé
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
// Tendance calculée une fois par ligne (et non à chaque tri ou rendu).
|
||||
const lignes = useMemo<Ligne[]>(
|
||||
() => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })),
|
||||
[rows],
|
||||
);
|
||||
}
|
||||
|
||||
const colonnes = useMemo<DataColumn<Ligne>[]>(() => [
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<Link href={href} className="font-medium text-[var(--accent)] hover:underline">
|
||||
{r.libelle || "—"}
|
||||
</Link>
|
||||
{r.nomenclature && (
|
||||
<div className="truncate text-xs text-[var(--text-muted)]" title={r.nomenclature}>{r.nomenclature}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fournisseur",
|
||||
header: "Fournisseur",
|
||||
sortValue: (r) => r.fournisseur,
|
||||
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur || "—"}</span>,
|
||||
},
|
||||
{
|
||||
id: "codeCentrale",
|
||||
header: "Code centrale",
|
||||
hint: GLOSSAIRE.codeCentrale.definition,
|
||||
cell: (r) => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codeCentrale}</span>,
|
||||
},
|
||||
{
|
||||
id: "magasins",
|
||||
header: "Magasins vendeurs",
|
||||
hint: GLOSSAIRE.presenceReseau.definition,
|
||||
align: "right",
|
||||
sortValue: (r) => r.nbMagasinsReseau,
|
||||
cell: (r) => (
|
||||
<>
|
||||
{fmtEntier(r.nbMagasinsReseau)}
|
||||
<span className="ml-1 text-xs text-[var(--text-muted)]">({fmtEntier(r.tauxPresence * 100)} %)</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => <span className="font-semibold">{fmtEntier(r.qteReseau)}</span>,
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<span className="font-medium" style={{ color: couleurMarge(r.margePctReseau) }}>
|
||||
{fmtDecimal1(r.margePctReseau)} %
|
||||
</span>
|
||||
) : "—",
|
||||
},
|
||||
{
|
||||
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 ? <TrendSparkline trend={r.trend} /> : <span className="text-[var(--text-muted)]">—</span>),
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<span className="text-[var(--text-secondary)]" title={`Code article ${r.codein}`}>
|
||||
Stock {fmtEntier(r.stockLocal ?? 0)}
|
||||
</span>
|
||||
) : (
|
||||
<Badge title="Le réseau vend ce produit, mais il n'est pas dans notre catalogue">Non référencé</Badge>
|
||||
),
|
||||
},
|
||||
], [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 (
|
||||
<th
|
||||
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
<DataTable
|
||||
rows={lignes}
|
||||
columns={colonnes}
|
||||
rowKey={(r) => r.codeCentrale}
|
||||
searchIn={rechercherDans}
|
||||
searchPlaceholder="Filtrer les résultats…"
|
||||
pageSize={50}
|
||||
unite={lignes.length > 1 ? "produits" : "produit"}
|
||||
emptyTitle="Aucun produit"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<label htmlFor="recherche-produit" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Nom du produit ou code centrale
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full max-w-xl flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
id="recherche-produit"
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={term.trim().length < 3}
|
||||
className="btn-action btn-action-primary disabled:opacity-50"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
<Button onClick={submit} disabled={term.trim().length < 3} className="h-10">
|
||||
<Search /> Rechercher
|
||||
</Button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<p id="recherche-produit-aide" className="text-[13px] text-[var(--text-secondary)]">
|
||||
Saisissez au moins 3 caractères.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,361 +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 <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc" ? <ChevronUp className="inline w-3 h-3 ml-1" /> : <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
||||
const STATUTS: Record<string, { libelle: string; ton: Ton }> = {
|
||||
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: { 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<string, string> = {
|
||||
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<T>(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 <Badge ton={s.ton}>{s.libelle}</Badge>;
|
||||
return statut ? <Badge>{statut}</Badge> : <span className="text-[var(--text-muted)]">—</span>;
|
||||
}
|
||||
|
||||
/** Intitulé d'abord, code de l'opération en petit dessous. */
|
||||
function Operation({ intitule, code }: { intitule: string; code: string }) {
|
||||
return (
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${cfg.cls}`}>
|
||||
{cfg.label}
|
||||
<div className="min-w-[220px]">
|
||||
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
|
||||
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Part du CA total : mise en avant à partir de 10 %, puis de 20 %. */
|
||||
function PartCa({ pct }: { pct: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
pct >= 20 ? "font-semibold text-[var(--accent)]" : pct >= 10 ? "font-semibold" : "text-[var(--text-secondary)]",
|
||||
)}
|
||||
>
|
||||
{fmtDecimal1(pct)} %
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <span className="text-[var(--text-muted)]" title="Chiffre d'affaires total inconnu : la part ne peut pas être calculée.">—</span>;
|
||||
}
|
||||
return (
|
||||
<InfoBulle explication="Somme du CA pub période des lignes affichées, divisée par la somme de leur CA total sur les mêmes périodes.">
|
||||
{fmtDecimal1((caPub / caTotal) * 100)} %
|
||||
</InfoBulle>
|
||||
);
|
||||
}
|
||||
|
||||
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>("vue", vueInitiale);
|
||||
|
||||
const [filterSite, setFilterSite] = useState<string>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function nav(params: Record<string, string | number>) {
|
||||
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 = (
|
||||
<Button variant="outline" onClick={() => startTransition(() => router.refresh())} disabled={chargement}>
|
||||
<RotateCcw /> Réessayer
|
||||
</Button>
|
||||
);
|
||||
|
||||
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<string, string> = 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<DataFilter<Publicite>[]>(
|
||||
() =>
|
||||
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<DataColumn<Publicite>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "operation",
|
||||
header: "Opération",
|
||||
sortValue: (p) => p.intitule,
|
||||
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
|
||||
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) => <StatutBadge statut={p.statut} />,
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
header: "Magasin",
|
||||
sortValue: (p) => (p.site ? nomMagasin(p.site) : null),
|
||||
cell: (p) => (p.site ? <StoreBadge code={p.site} /> : <span className="text-[var(--text-muted)]">—</span>),
|
||||
},
|
||||
{
|
||||
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: <InfoBulle explication={EXPLICATION_CA_PUB}>CA pub période</InfoBulle>,
|
||||
align: "right",
|
||||
sortValue: (p) => nombre(p.ca_pub_periode_pub),
|
||||
cell: (p) => <span className="font-semibold">{fmtEur0(nombre(p.ca_pub_periode_pub))}</span>,
|
||||
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)),
|
||||
},
|
||||
{
|
||||
id: "partCa",
|
||||
header: <Terme id="partCaTotal" />,
|
||||
align: "right",
|
||||
sortValue: (p) => nombre(p.pourc_capub_catotal),
|
||||
cell: (p) => <PartCa pct={nombre(p.pourc_capub_catotal)} />,
|
||||
footer: (rows) => <PartCaTotal rows={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: <Terme id="tauxSortie" />,
|
||||
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<DataFilter<PubliciteHistorique>[]>(
|
||||
() => [{ 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<DataColumn<PubliciteHistorique>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "operation",
|
||||
header: "Opération",
|
||||
sortValue: (h) => h.intitule,
|
||||
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />,
|
||||
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
|
||||
},
|
||||
{
|
||||
id: "statut",
|
||||
header: "Statut",
|
||||
sortValue: (h) => libelleStatut(h.statut),
|
||||
cell: (h) => <StatutBadge statut={h.statut} />,
|
||||
},
|
||||
{
|
||||
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) => <span className="font-semibold">{fmtEur0(nombre(h.ca_total))}</span>,
|
||||
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 = (
|
||||
<Select
|
||||
id="publicites-statut"
|
||||
label="Statut"
|
||||
value={statut}
|
||||
options={OPTIONS_STATUT}
|
||||
onChange={(v) => naviguer({ statut: v, page: 1 })}
|
||||
disabled={chargement}
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionVide =
|
||||
page > 1 && total > 0 ? (
|
||||
<>
|
||||
Cette page ne contient aucune publicité.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => naviguer({ page: 1 })}
|
||||
className="font-medium text-[var(--accent)] underline underline-offset-2"
|
||||
>
|
||||
Revenir à la première page
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{VIDE_PAR_STATUT[statut] ?? VIDE_PAR_STATUT.toutes}
|
||||
{statut !== "toutes" && " Choisissez « Toutes » dans le filtre Statut pour voir les autres opérations."}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Onglets ── */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{[
|
||||
{ key: "publicites", label: `Publicités${total ? ` (${total})` : ""}` },
|
||||
{ key: "historique", label: `Historique${historique.length ? ` (${historique.length})` : ""}` },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => nav({ vue: t.key, page: 1 })}
|
||||
className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
|
||||
vue === t.key
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Tabs items={onglets} value={vue} onChange={setVue} />
|
||||
|
||||
{/* ── Barre de contrôles ── */}
|
||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
{!isHistorique && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Statut</label>
|
||||
<select
|
||||
value={statut}
|
||||
onChange={e => 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]"
|
||||
>
|
||||
<option value="toutes">Toutes</option>
|
||||
<option value="en_cours">En cours</option>
|
||||
<option value="passees">Passées</option>
|
||||
<option value="a_venir">À venir</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{sites.length > 1 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Magasin</label>
|
||||
<select
|
||||
value={filterSite}
|
||||
onChange={e => setFilterSite(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-orange-400 min-w-[160px]"
|
||||
>
|
||||
<option value="all">Tous les magasins</option>
|
||||
{sites.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{total} publicité{total !== 1 ? "s" : ""} au total
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isHistorique && (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{historique.length} opération{historique.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Vue Publicités ── */}
|
||||
{!isHistorique && (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">Aucune publicité trouvée.</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
||||
<th {...thProps("intitule", "px-3 py-3 text-left text-xs font-semibold text-gray-500")}>
|
||||
Opération <SortIcon col="intitule" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("statut", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Statut <SortIcon col="statut" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("site", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Magasin <SortIcon col="site" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
||||
<th {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("pourc_capub_catotal", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
% CA total <SortIcon col="pourc_capub_catotal" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("qte_vendue_pub", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Qté vendue <SortIcon col="qte_vendue_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Clients</th>
|
||||
<th {...thProps("taux_sortie", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Taux sortie <SortIcon col="taux_sortie" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Nb articles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((pub, idx) => (
|
||||
<tr key={`${pub.tcr_code}-${pub.site}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{pub.tcr_code}</td>
|
||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{pub.intitule}</td>
|
||||
<td className="px-3 py-2.5"><StatutBadge statut={pub.statut} /></td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{pub.site || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(pub.date_debut)}</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(pub.date_fin)}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(pub.ca_pub_periode_pub)}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right tabular-nums">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
Number(pub.pourc_capub_catotal) >= 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)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.client_pub_periode).toLocaleString("fr-FR")}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-500 text-xs">{pub.nb_articles}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages})
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))}
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right text-xs font-semibold text-orange-700">—</td>
|
||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-white px-5 py-3 shadow-sm border border-gray-100">
|
||||
<span className="text-sm text-gray-500">
|
||||
Page <strong>{page}</strong> sur <strong>{pages}</strong> — {total} publicités
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => nav({ vue, statut, page: page - 1 })}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Préc.
|
||||
</button>
|
||||
{/* 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 (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => nav({ vue, statut, page: p })}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium ${
|
||||
p === page
|
||||
? "bg-orange-500 text-white"
|
||||
: "border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => nav({ vue, statut, page: page + 1 })}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Suiv. <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Vue Historique ── */}
|
||||
{isHistorique && (
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{historique.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">Aucun historique disponible.</div>
|
||||
<div aria-busy={chargement} className={cn("transition-opacity", chargement && "pointer-events-none opacity-60")}>
|
||||
{vue === "publicites" ? (
|
||||
erreurPublicites ? (
|
||||
<ErrorState
|
||||
title="Les publicités n'ont pas pu être chargées"
|
||||
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
|
||||
detail={erreurPublicites}
|
||||
action={reessayer}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Opération</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Statut</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Sites</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50">CA total</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Qté totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historique.map((h, idx) => (
|
||||
<tr key={`${h.tcr_code}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{h.tcr_code}</td>
|
||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{h.intitule}</td>
|
||||
<td className="px-3 py-2.5"><StatutBadge statut={h.statut} /></td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_debut)}</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_fin)}</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-500">{h.nb_sites}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(h.ca_total)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(h.qte_totale).toLocaleString("fr-FR")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {historique.length} opérations
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||
{historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{paginee && (
|
||||
<p className="flex items-start gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
|
||||
<span>
|
||||
<span className="font-semibold text-[var(--text-primary)]">
|
||||
Tri et filtre magasin appliqués à cette page.
|
||||
</span>{" "}
|
||||
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.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<DataTable
|
||||
rows={validPublicites}
|
||||
columns={colonnesPublicites}
|
||||
rowKey={(p, i) => `${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}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={(p) => naviguer({ page: p })}
|
||||
unite="publicités"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : erreurHistorique ? (
|
||||
<ErrorState
|
||||
title="L'historique n'a pas pu être chargé"
|
||||
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
|
||||
detail={erreurHistorique}
|
||||
action={reessayer}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={historique}
|
||||
columns={colonnesHistorique}
|
||||
rowKey={(h, i) => `${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."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function PublicitesLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des publicités..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={10} />;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
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;
|
||||
@@ -40,17 +46,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<PublicitesResult> {
|
||||
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<PublicitesResult> {
|
||||
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 +83,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<HistoriqueResult> {
|
||||
return cachedFF("publicites-historique", fetchHistoriqueApi, {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHistoriqueApi(): Promise<HistoriqueResult> {
|
||||
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[] };
|
||||
@@ -87,27 +119,25 @@ export default async function PublicitesPage(props: {
|
||||
fetchHistorique(),
|
||||
]);
|
||||
|
||||
const error = pubResult.error ?? histoResult.error;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Publicités</h1>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PublicitesClient
|
||||
vue={vue}
|
||||
statut={statut}
|
||||
page={page}
|
||||
publicites={pubResult.data}
|
||||
total={pubResult.total}
|
||||
pages={pubResult.pages}
|
||||
historique={histoResult.data}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={Megaphone}
|
||||
title="Publicités"
|
||||
description="Résultats des opérations publicitaires (en cours, passées, à venir) et leur poids dans les ventes de vos magasins."
|
||||
/>
|
||||
<PublicitesClient
|
||||
vueInitiale={vue === "historique" ? "historique" : "publicites"}
|
||||
statut={statut}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
publicites={pubResult.data}
|
||||
total={pubResult.total}
|
||||
pages={pubResult.pages}
|
||||
erreurPublicites={pubResult.error}
|
||||
historique={histoResult.data}
|
||||
erreurHistorique={histoResult.error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"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,
|
||||
getParametresAffichables,
|
||||
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. Elle ne contient aucun mot de
|
||||
* passe : seulement l'indication qu'il y en a un d'enregistré.
|
||||
*/
|
||||
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getParametresAffichables().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<Onglet>[] = [
|
||||
{ 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 (
|
||||
<div role="tabpanel" hidden={!actif} className="space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Éléments de formulaire ───────────────────────────────────────────────────
|
||||
|
||||
function Champ({ id, label, aide, children, className }: {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
aide?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
{aide && <p className="mt-1 text-xs text-[var(--text-muted)]">{aide}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge ton="succes">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Connexion réussie
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (test.etat === "erreur") {
|
||||
return (
|
||||
<p role="alert" className="flex items-start gap-1.5 text-[13px] text-[var(--accent-error)]">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
<span className="font-semibold">Connexion impossible.</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">{test.message}</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */
|
||||
function placeholderMotDePasse(enregistre: boolean): string {
|
||||
return enregistre ? "•••••• (inchangé)" : "••••••••";
|
||||
}
|
||||
|
||||
const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré.";
|
||||
|
||||
// ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
const {
|
||||
host, setHost,
|
||||
port, setPort,
|
||||
database, setDatabase,
|
||||
user, setUser,
|
||||
password, setPassword,
|
||||
ssl, setSsl,
|
||||
motDePasseEnregistre, setMotDePasseEnregistre,
|
||||
} = useDbSettingsStore();
|
||||
|
||||
// Mot de passe vide : le serveur reprend celui qui est enregistré.
|
||||
const champs = () => ({ host, port, database, user, password: password || undefined, ssl });
|
||||
|
||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const [rechargement, setRechargement] = useState(false);
|
||||
|
||||
const tester = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testDatabaseConnection(champs());
|
||||
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
|
||||
} catch (e) {
|
||||
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const enregistrer = async () => {
|
||||
setEnregistrement(true);
|
||||
try {
|
||||
const res = await saveDatabaseSettings(champs());
|
||||
if (res.success) {
|
||||
toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
||||
if (password) {
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(true);
|
||||
}
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Base de données de l'application (PostgreSQL)"
|
||||
description="Contient les comptes utilisateurs, l'historique des sessions et les réglages. Les produits et les ventes, eux, viennent de l'API FF Nancy."
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Champ
|
||||
id="pg-hote"
|
||||
label="Serveur"
|
||||
aide="Adresse du serveur : localhost, une adresse IP (192.168.1.10) ou le nom du service Docker (postgres, db)."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input id="pg-hote" className="w-full font-mono" placeholder="localhost" value={host}
|
||||
onChange={(e) => setHost(e.target.value)} />
|
||||
</Champ>
|
||||
<Champ id="pg-port" label="Port" aide="5432 dans la plupart des installations.">
|
||||
<Input id="pg-port" className="w-full font-mono" placeholder="5432" value={port}
|
||||
onChange={(e) => setPort(e.target.value)} />
|
||||
</Champ>
|
||||
<Champ id="pg-base" label="Nom de la base">
|
||||
<Input id="pg-base" className="w-full font-mono" placeholder="collectflow" value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)} />
|
||||
</Champ>
|
||||
<Champ id="pg-utilisateur" label="Utilisateur">
|
||||
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
||||
</Champ>
|
||||
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
|
||||
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
</Champ>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pg-ssl" className="inline-flex cursor-pointer items-center gap-2 text-sm text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="pg-ssl"
|
||||
checked={ssl}
|
||||
onChange={(e) => setSsl(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
Connexion chiffrée (SSL)
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
<Button onClick={enregistrer} disabled={enregistrement}>
|
||||
{enregistrement ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button variant="outline" onClick={tester} disabled={test.etat === "en-cours"}>
|
||||
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={rechargerConfig} disabled={rechargement}>
|
||||
{rechargement ? <Loader2 className="animate-spin" /> : <RotateCcw />}
|
||||
Recharger les valeurs enregistrées
|
||||
</Button>
|
||||
<ResultatTest test={test} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<FfSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testedUrl, setTestedUrl] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="API FF Nancy"
|
||||
description="Source des produits et des ventes de nos magasins. Ses données sont mises à jour chaque nuit depuis la base FF : elles s'arrêtent donc à la veille."
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<Champ
|
||||
id="ff-url"
|
||||
label="Adresse de l'API"
|
||||
aide="Laissez vide pour utiliser l'adresse par défaut (https://api.ffnancy.fr)."
|
||||
>
|
||||
<Input
|
||||
id="ff-url"
|
||||
type="text"
|
||||
placeholder="https://api.ffnancy.fr"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
||||
className="w-full font-mono"
|
||||
/>
|
||||
</Champ>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
<ResultatTest test={test} />
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
||||
{!savedUrl && <> (adresse par défaut, aucun réglage enregistré)</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Dernière mise à jour complète des données :{" "}
|
||||
<span className="font-medium text-[var(--text-primary)]">{fmtDateHeure(status.lastSync)}</span>
|
||||
</p>
|
||||
{status.tables?.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left">Table de données</th>
|
||||
<th scope="col" className="px-3 py-2 text-right">Mise à jour</th>
|
||||
<th scope="col" className="px-3 py-2 text-right">Lignes</th>
|
||||
<th scope="col" className="px-3 py-2 text-right">État</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.tables.map((t) => (
|
||||
<tr key={t.nom} className="border-t border-[var(--border)]">
|
||||
<td className="px-3 py-2 text-sm font-mono text-[var(--text-primary)]">{t.nom}</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-[var(--text-secondary)]">{fmtDateHeure(t.derniereSync)}</td>
|
||||
<td className="px-3 py-2 text-sm text-right tabular-nums text-[var(--text-secondary)]">
|
||||
{t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"}
|
||||
</td>
|
||||
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
||||
<td className="px-3 py-2 text-sm text-right">
|
||||
{t.statut === "ok"
|
||||
? <Badge ton="succes">OK</Badge>
|
||||
: <Badge ton="erreur" title={t.erreur ?? undefined}>{t.statut ?? "—"}</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Qlik Sense ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionQlik() {
|
||||
const [host, setHost] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
// Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur.
|
||||
const [password, setPassword] = useState("");
|
||||
const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedConfig()
|
||||
.then((c) => {
|
||||
setHost(c.qlik.host);
|
||||
setUser(c.qlik.user);
|
||||
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
|
||||
})
|
||||
.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 || undefined);
|
||||
if (res.success) {
|
||||
toast.succes("Identifiants Qlik enregistrés.");
|
||||
if (password) {
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(true);
|
||||
}
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password || undefined);
|
||||
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
|
||||
} catch (e) {
|
||||
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
|
||||
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Qlik Sense (données du réseau)"
|
||||
description={
|
||||
<>
|
||||
Identifiants utilisés pour récupérer les ventes des magasins du{" "}
|
||||
<Terme id="reseau">réseau</Terme> dans l'outil de reporting Qlik Sense de La Foir'Fouille.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<Champ
|
||||
id="qlik-hote"
|
||||
label="Serveur Qlik"
|
||||
aide="Laissez vide pour utiliser le serveur habituel : reporting-magasins.lafoirfouille.fr."
|
||||
>
|
||||
<Input id="qlik-hote" type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
||||
onChange={(e) => setHost(e.target.value)} className="w-full font-mono" />
|
||||
</Champ>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Champ
|
||||
id="qlik-utilisateur"
|
||||
label="Identifiant"
|
||||
aide="Identifiant Windows sans le domaine, par exemple FFSCH (connexion par authentification Windows, dite NTLM)."
|
||||
>
|
||||
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
||||
</Champ>
|
||||
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<div className="relative">
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowPwd((v) => !v)}
|
||||
aria-label={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||
title={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
|
||||
>
|
||||
{showPwd ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
</Champ>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={test.etat === "en-cours" || testImpossible}
|
||||
title={testImpossible ? "Renseignez l'identifiant et le mot de passe pour tester" : undefined}
|
||||
>
|
||||
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
<ResultatTest test={test} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) {
|
||||
const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions";
|
||||
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
||||
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => 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 setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre);
|
||||
|
||||
const reloadFromServer = useCallback(async (force = false) => {
|
||||
const { db } = await loadSavedConfig(force);
|
||||
// Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré.
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false);
|
||||
if (db) {
|
||||
setHost(db.host);
|
||||
setPort(db.port);
|
||||
setDatabase(db.database);
|
||||
setUser(db.user);
|
||||
setSsl(db.ssl);
|
||||
}
|
||||
}, [setDatabase, setHost, setMotDePasseEnregistre, 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 (
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="Paramètres"
|
||||
description="Connexions aux sources de données, comptes des utilisateurs et clés d'accès à l'API (réservé aux administrateurs)."
|
||||
/>
|
||||
|
||||
<Tabs items={ONGLETS} value={onglet} onChange={changerOnglet} className="mb-6" />
|
||||
|
||||
{isMounted && (
|
||||
<>
|
||||
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
||||
<SectionPostgres recharger={recharger} />
|
||||
<SectionFfApi />
|
||||
<SectionQlik />
|
||||
</Panneau>
|
||||
|
||||
<Panneau actif={onglet === "utilisateurs"} visite={visites.has("utilisateurs")}>
|
||||
<UserManagement />
|
||||
</Panneau>
|
||||
|
||||
<Panneau actif={onglet === "api"} visite={visites.has("api")}>
|
||||
<ApiConnectionInfo />
|
||||
<ApiKeyManagement />
|
||||
<GridWarmup />
|
||||
</Panneau>
|
||||
|
||||
<Panneau actif={onglet === "journal"} visite={visites.has("journal")}>
|
||||
<ServerLogs />
|
||||
</Panneau>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des paramètres..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={6} />;
|
||||
}
|
||||
@@ -1,654 +1,21 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
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 } from "@/features/settings/actions";
|
||||
import { useEffect } from "react";
|
||||
import { UserManagement } from "@/features/admin/components/user-management";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
export const metadata: Metadata = { title: "Paramètres" };
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
|
||||
// Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
<div className="apple-card-header">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
{subtitle && <p className="text-[12px] mt-0.5 text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="apple-card-content space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] text-[var(--text-muted)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; }
|
||||
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
||||
|
||||
function FfApiStatusSection() {
|
||||
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/ff-status");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setStatus(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchStatus(); }, [fetchStatus]);
|
||||
|
||||
return (
|
||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Vérifier le statut
|
||||
</button>
|
||||
{error && <span className="text-[12px] text-red-400">{error}</span>}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Dernière sync globale : <span className="font-mono font-medium">{(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()}</span>
|
||||
</p>
|
||||
{status.tables?.length > 0 && (
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--border-subtle)]">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-tertiary)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.tables.map((t) => (
|
||||
<tr key={t.nom} className="border-t border-[var(--border-subtle)]">
|
||||
<td className="px-3 py-2 font-mono text-[var(--text-primary)]">{t.nom}</td>
|
||||
<td className="px-3 py-2 text-right text-[var(--text-secondary)]">
|
||||
{(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
getSavedDatabaseConfig().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 (
|
||||
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
|
||||
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
|
||||
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
||||
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
|
||||
<input type="text" placeholder="FFSCH" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Mot de passe">
|
||||
<div className="relative">
|
||||
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
|
||||
<button type="button" onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{status && (
|
||||
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{status.msg}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Enregistrer
|
||||
</button>
|
||||
<button onClick={handleTest} disabled={testing || !user || !password}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
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 [showKey, setShowKey] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [models, setModels] = useState<OpenRouterModel[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
|
||||
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
// AI Provider
|
||||
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
|
||||
const [googleAiKey, setGoogleAiKey] = useState("");
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [googleAiModel, setGoogleAiModel] = useState("");
|
||||
const [googleModels, setGoogleModels] = useState<{ id: string; name: string }[]>([]);
|
||||
const [googleModelsStatus, setGoogleModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const {
|
||||
host, setHost,
|
||||
port, setPort,
|
||||
database, setDatabase,
|
||||
user, setUser,
|
||||
password, setPassword,
|
||||
ssl, setSsl,
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const fetchGoogleModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setGoogleModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/google-ai/models", { headers: { "x-google-ai-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setGoogleModels(data.models ?? []);
|
||||
setGoogleModelsStatus("ok");
|
||||
} catch {
|
||||
setGoogleModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setModels(data.models ?? []);
|
||||
setModelsStatus("ok");
|
||||
} catch {
|
||||
setModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadFromServer = useCallback(async () => {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
if (config) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (config.openRouterKey) {
|
||||
setApiKey(config.openRouterKey);
|
||||
fetchModels(config.openRouterKey);
|
||||
}
|
||||
if (config.openRouterModel) {
|
||||
setSelectedModel(config.openRouterModel);
|
||||
}
|
||||
if (config.aiProvider) setAiProvider(config.aiProvider);
|
||||
if (config.googleAiKey) {
|
||||
setGoogleAiKey(config.googleAiKey);
|
||||
fetchGoogleModels(config.googleAiKey);
|
||||
}
|
||||
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
|
||||
}
|
||||
}, [fetchModels, fetchGoogleModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
reloadFromServer();
|
||||
}, [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, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
|
||||
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 (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold tracking-[-0.4px] text-[var(--text-primary)]">Paramètres</h1>
|
||||
<p className="text-[13px] mt-1 text-[var(--text-secondary)]">Configuration de la connexion et des services.</p>
|
||||
</div>
|
||||
|
||||
{/* PostgreSQL */}
|
||||
<Section title="PostgreSQL — Auth & Historique" subtitle="Connexion pour les comptes utilisateurs, snapshots et contexte IA. Les données produits proviennent de l'API FF Nancy.">
|
||||
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="localhost"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="apple-input font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Port">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="5432"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="apple-input font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Base de données">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="collectflow"
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
className="apple-input font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Utilisateur">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="postgres"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
className="apple-input font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Mot de passe">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password || ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="apple-input font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ssl"
|
||||
checked={ssl}
|
||||
onChange={(e) => setSsl(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="ssl" className="text-[12px] cursor-pointer text-[var(--text-secondary)]">Connexion SSL</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary h-9 px-4">
|
||||
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
|
||||
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||
Tester la connexion
|
||||
</button>
|
||||
{dbStatus === "error" && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[12px] text-red-400 font-medium">Connexion refusée</span>
|
||||
{dbError && <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{dbError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{dbStatus === "ok" && (
|
||||
<span className="text-[12px] text-teal-500 font-medium">Connexion réussie !</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={reloadFromServer}
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Recharger la configuration enregistrée sur le serveur
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
<QlikSettingsSection />
|
||||
|
||||
{/* AI Provider selector */}
|
||||
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAiProvider("openrouter")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
OpenRouter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider("google")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
Google AI Studio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiProvider === "google" && (
|
||||
<>
|
||||
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showGoogleKey ? "text" : "password"}
|
||||
placeholder="AIzaSy..."
|
||||
value={googleAiKey}
|
||||
onChange={(e) => setGoogleAiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowGoogleKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchGoogleModels(googleAiKey)}
|
||||
disabled={!googleAiKey || googleModelsStatus === "loading"}
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{googleModelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={`Modèle Google${googleModels.length > 0 ? ` (${googleModels.length} disponibles)` : ""}`}
|
||||
hint={googleModelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : googleModelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
{googleModels.length > 0 ? (
|
||||
<select
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{googleModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
placeholder="ex: gemini-2.0-flash"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{aiProvider === "openrouter" && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
placeholder="sk-or-v1-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchModels(apiKey)}
|
||||
disabled={!apiKey || modelsStatus === "loading"}
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
|
||||
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
{models.length > 0 ? (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.free ? "🆓 " : ""}{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="ex: google/gemini-2.0-flash-001"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Apparence */}
|
||||
<Section title="Apparence">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">Thème</p>
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">Interface claire ou sombre</p>
|
||||
</div>
|
||||
{/* Segment control */}
|
||||
<div className="segment-control">
|
||||
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
|
||||
<Sun className="w-3.5 h-3.5" /> Clair
|
||||
</button>
|
||||
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
|
||||
<Moon className="w-3.5 h-3.5" /> Sombre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">Densité de la grille</p>
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">Hauteur de ligne par défaut</p>
|
||||
</div>
|
||||
<select
|
||||
className="apple-input w-auto"
|
||||
value={displayDensity}
|
||||
onChange={(e) => setDisplayDensity(e.target.value as "compact" | "normal" | "comfortable")}
|
||||
>
|
||||
<option value="compact">Compact (32px)</option>
|
||||
<option value="normal">Normal (40px)</option>
|
||||
<option value="comfortable">Spacieux (48px)</option>
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Score Produit */}
|
||||
<Section title="Score Produit" subtitle="Formule hybride absolue + relative (v4)">
|
||||
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 space-y-2">
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
<strong>Formule :</strong> Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.
|
||||
</p>
|
||||
<div className="border-t border-[var(--border-subtle)] pt-2 mt-2 space-y-1">
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Volume</strong> (0-50 pts) — unités / magasin / mois</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>CA</strong> (0-30 pts) — € / magasin / an</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Marge</strong> (0-20 pts) — taux de marge %</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Bonus relatif</strong> (±10 pts) — percentile composite dans le lot</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-muted)] mt-2">
|
||||
Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Journaux serveur — diagnostic des extractions Qlik */}
|
||||
<Section
|
||||
title="Journaux serveur"
|
||||
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
|
||||
>
|
||||
<ServerLogs />
|
||||
</Section>
|
||||
|
||||
{/* Gestion des Utilisateurs */}
|
||||
<Section
|
||||
title="Gestion des Utilisateurs"
|
||||
subtitle="Administration des accès et des rôles"
|
||||
>
|
||||
<UserManagement />
|
||||
</Section>
|
||||
|
||||
{/* Save Button — Floating/Sticky style at bottom */}
|
||||
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving"}
|
||||
className={`apple-btn-primary min-w-[240px] shadow-lg ${saveStatus === "saving" ? "animate-pulse" : saveStatus === "saved" ? "animate-apple-save" : ""}`}
|
||||
style={{ background: saveStatus === "saved" ? "var(--accent-success)" : "var(--brand-solid)" }}
|
||||
>
|
||||
{saveStatus === "saving" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : saveStatus === "saved" ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{saveStatus === "saving"
|
||||
? "Enregistrement en cours..."
|
||||
: saveStatus === "saved"
|
||||
? "Configuration enregistrée !"
|
||||
: "Sauvegarder la configuration"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const { onglet } = await props.searchParams;
|
||||
return <ParametresClient onglet={onglet} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function SnapshotsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des snapshots..." />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<Camera className="w-5 h-5 text-[var(--accent)]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-[var(--text-primary)] uppercase">Snapshots</h1>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-xl">
|
||||
Historique de vos sessions d'arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnapshotList type="snapshot" />
|
||||
</div>
|
||||
);
|
||||
redirect("/historique");
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react";
|
||||
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
|
||||
import { 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;
|
||||
|
||||
function fmtDate(raw: string | null) {
|
||||
if (!raw) return "—";
|
||||
@@ -21,465 +30,275 @@ function getYear(raw: string | null): string {
|
||||
return String(d.getFullYear());
|
||||
}
|
||||
|
||||
function SortIcon<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
/**
|
||||
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
|
||||
* Évite le clignotement de l'indicateur sur les transitions instantanées.
|
||||
*/
|
||||
function useDelayedFlag(active: boolean, delay = 120) {
|
||||
const [elapsed, setElapsed] = useState(false);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
|
||||
function FilterSelect({ label, value, onChange, options }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-600 whitespace-nowrap">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(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-[150px]"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function handleSort(key: keyof T) {
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
// Réinitialisation pendant le rendu (et non dans un effet) : dès que
|
||||
// l'attente cesse, le prochain délai repart de zéro.
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
if (!active) setElapsed(false);
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => [...rows].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
if (numericKeys.includes(sortKey as string)) {
|
||||
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
||||
}
|
||||
const as = String(av ?? "").toLowerCase();
|
||||
const bs = String(bv ?? "").toLowerCase();
|
||||
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
|
||||
}), [rows, sortKey, sortDir]);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setTimeout(() => setElapsed(true), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [active, delay]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
return active && elapsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 1 — Stock Négatif
|
||||
// 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.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockNegatifRow;
|
||||
interface ExcelSpec<T> {
|
||||
sheet: string;
|
||||
filePrefix: string;
|
||||
header: string[];
|
||||
widths: number[];
|
||||
row: (r: T) => (string | number)[];
|
||||
}
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
async function exporterPourFF<T>(excel: ExcelSpec<T>, 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);
|
||||
}
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[rows]
|
||||
);
|
||||
const EXCEL_NEGATIF: ExcelSpec<PgStockNegatifRow> = {
|
||||
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 filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search]);
|
||||
const EXCEL_SANS_VENTE: ExcelSpec<PgStockSansVenteRow> = {
|
||||
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 { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
|
||||
filtered,
|
||||
["stockdispo"]
|
||||
);
|
||||
const EXCEL_SANS_VENTE_6MOIS: ExcelSpec<PgSansVente6MoisRow> = {
|
||||
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)],
|
||||
};
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colonnes et filtres des 3 onglets
|
||||
//
|
||||
// Définis au niveau module : leur identité doit rester stable d'un rendu à
|
||||
// l'autre, sinon les `useMemo` du tableau se recalculent en boucle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Stock Négatif");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
interface LigneStock {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
fournisseur: string;
|
||||
site: string;
|
||||
}
|
||||
|
||||
/** Colonnes communes aux 3 onglets : code, libellé, fournisseur, magasin. */
|
||||
function colonnesIdentite<T extends LigneStock>(): DataColumn<T>[] {
|
||||
return [
|
||||
{
|
||||
id: "codein",
|
||||
header: "Code article",
|
||||
sortValue: r => r.codein,
|
||||
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
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 => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
header: "Magasin",
|
||||
sortValue: r => nomMagasin(r.site),
|
||||
cell: r => <StoreBadge code={r.site} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Quantité en stock : rouge si négative, en clair sinon. */
|
||||
function celluleStock(v: number) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
<span
|
||||
className={cn("font-semibold", v < 0 ? "text-[var(--accent-error)]" : "text-[var(--text-primary)]")}
|
||||
>
|
||||
{fmtDecimal1(v)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const celluleDate = (v: string | null) => (
|
||||
<span className="whitespace-nowrap text-[13px] text-[var(--text-secondary)]">{fmtDate(v)}</span>
|
||||
);
|
||||
|
||||
const rechercheStock = (r: LigneStock) => [r.codein, r.libelle1];
|
||||
|
||||
function filtreFournisseur<T extends LigneStock>(): DataFilter<T> {
|
||||
return { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" };
|
||||
}
|
||||
|
||||
function filtreAnneeEntree<T>(anneeOf: (r: T) => string): DataFilter<T> {
|
||||
return { id: "annee", label: "Année d'entrée", valueOf: anneeOf, allLabel: "Toutes les années", order: "desc" };
|
||||
}
|
||||
|
||||
const COLONNES_NEGATIF: DataColumn<PgStockNegatifRow>[] = [
|
||||
...colonnesIdentite<PgStockNegatifRow>(),
|
||||
{
|
||||
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<PgStockNegatifRow>[] = [
|
||||
filtreFournisseur<PgStockNegatifRow>(),
|
||||
filtreAnneeEntree<PgStockNegatifRow>(r => getYear(r.derniereentree)),
|
||||
];
|
||||
|
||||
const COLONNES_SANS_VENTE: DataColumn<PgStockSansVenteRow>[] = [
|
||||
...colonnesIdentite<PgStockSansVenteRow>(),
|
||||
{
|
||||
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<PgStockSansVenteRow>[] = [
|
||||
filtreFournisseur<PgStockSansVenteRow>(),
|
||||
filtreAnneeEntree<PgStockSansVenteRow>(r => getYear(r.derniere_entree)),
|
||||
];
|
||||
|
||||
const COLONNES_SANS_VENTE_6MOIS: DataColumn<PgSansVente6MoisRow>[] = [
|
||||
...colonnesIdentite<PgSansVente6MoisRow>(),
|
||||
{
|
||||
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 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
r.jours_sans_vente > 365 ? "text-[var(--accent-error)]" : "text-[var(--text-secondary)]",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stockdispo", "Stock")}
|
||||
{th("dernierevente", "Dernière vente")}
|
||||
{th("derniereentree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{fmtEntier(r.jours_sans_vente)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--text-muted)]">Jamais vendu</span>
|
||||
),
|
||||
},
|
||||
{ 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<PgSansVente6MoisRow>[] = [filtreFournisseur<PgSansVente6MoisRow>()];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 2 — Entrées sans vente
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockSansVenteRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[baseRows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[baseRows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return baseRows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [baseRows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
|
||||
filtered,
|
||||
["stock_actuel"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Entrées sans vente");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stock_actuel", "Stock actuel")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 3 — Sans vente depuis 6 mois
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) {
|
||||
type SK = keyof PgSansVente6MoisRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgSansVente6MoisRow & Record<string, unknown>>(
|
||||
filtered as (PgSansVente6MoisRow & Record<string, unknown>)[],
|
||||
["stock_actuel", "jours_sans_vente"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key as string & keyof (PgSansVente6MoisRow & Record<string, unknown>))}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Sans vente 6 mois");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Sans_Vente_6Mois_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
{(filterFournisseur || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stock_actuel", "Stock actuel")}
|
||||
{th("derniere_vente", "Dernière vente")}
|
||||
{th("jours_sans_vente", "Jours sans vente")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={8} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_vente)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{ color: row.jours_sans_vente && row.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}>
|
||||
{row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const 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 (TAB_KEYS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** Ce que liste chaque onglet, et quoi en faire. */
|
||||
const EXPLICATIONS: Record<TabKey, ReactNode> = {
|
||||
"negatif": (
|
||||
<>
|
||||
Produits dont le <Terme id="stockNegatif">stock est négatif</Terme> : 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[];
|
||||
@@ -487,81 +306,126 @@ interface Props {
|
||||
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 activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey;
|
||||
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
|
||||
|
||||
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const val = e.target.value;
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||
// Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés.
|
||||
const [switching, startSwitch] = useTransition();
|
||||
// Le changement de magasin, lui, relance les requêtes serveur.
|
||||
const [navigating, startNavigation] = useTransition();
|
||||
|
||||
// Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct).
|
||||
const [urlTab, setUrlTab] = useState(initialTab);
|
||||
if (urlTab !== initialTab) {
|
||||
setUrlTab(initialTab);
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
function buildUrl(nextTab: TabKey, nextMagasin: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (val) params.set("magasin", val);
|
||||
params.set("tab", activeTab);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
if (nextMagasin) params.set("magasin", nextMagasin);
|
||||
params.set("tab", nextTab);
|
||||
return `/stock-negatif?${params.toString()}`;
|
||||
}
|
||||
|
||||
function handleMagasinChange(val: string) {
|
||||
startNavigation(() => {
|
||||
router.push(buildUrl(activeTab, val));
|
||||
});
|
||||
}
|
||||
|
||||
function handleTabChange(key: TabKey) {
|
||||
const params = new URLSearchParams();
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
params.set("tab", key);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
if (key === activeTab || switching) return;
|
||||
// Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée.
|
||||
window.history.replaceState(null, "", buildUrl(key, magasin));
|
||||
startSwitch(() => setActiveTab(key));
|
||||
}
|
||||
|
||||
const tabs: TabItem<TabKey>[] = 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);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Magasin</label>
|
||||
<select
|
||||
value={magasin}
|
||||
onChange={handleMagasinChange}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
<option key={s.code} value={s.code}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4" aria-busy={busy}>
|
||||
{/* Choix du magasin */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Select
|
||||
id="stock-magasin"
|
||||
label="Magasin"
|
||||
value={magasin}
|
||||
onChange={handleMagasinChange}
|
||||
disabled={navigating}
|
||||
placeholder={LIBELLE_TOUS_MAGASINS}
|
||||
options={OPTIONS_MAGASINS}
|
||||
/>
|
||||
{showLoader && (
|
||||
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Chargement…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-1">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={[
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
activeTab === t.key
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
<span className={[
|
||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||
].join(" ")}>
|
||||
{t.key === "negatif"
|
||||
? rowsNegatif.length
|
||||
: t.key === "sans-vente"
|
||||
? rowsSansVente.filter(r => r.stock_actuel !== 0).length
|
||||
: rowsSansVente6Mois.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<Tabs items={tabs} value={activeTab} onChange={handleTabChange} disabled={busy} />
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
|
||||
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
|
||||
{activeTab === "sans-vente-6mois" && <TabSansVente6Mois rows={rowsSansVente6Mois} magasin={magasin} />}
|
||||
<p className="max-w-4xl text-sm text-[var(--text-secondary)]">{EXPLICATIONS[activeTab]}</p>
|
||||
|
||||
<div className={cn("transition-opacity", showLoader && "pointer-events-none opacity-50")}>
|
||||
{activeTab === "negatif" && (
|
||||
<DataTable<PgStockNegatifRow>
|
||||
rows={rowsNegatif}
|
||||
columns={COLONNES_NEGATIF}
|
||||
rowKey={cleLigne}
|
||||
searchIn={rechercheStock}
|
||||
searchPlaceholder="Code article ou libellé…"
|
||||
filters={FILTRES_NEGATIF}
|
||||
pageSize={PAGE_SIZE}
|
||||
unite="articles"
|
||||
emptyTitle="Aucun article en stock négatif"
|
||||
emptyDescription="Aucun stock n'est inférieur à zéro pour ce choix de magasin : rien à régulariser."
|
||||
onExport={rows => exporterPourFF(EXCEL_NEGATIF, rows, magasin)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "sans-vente" && (
|
||||
<DataTable<PgStockSansVenteRow>
|
||||
rows={rowsSansVente}
|
||||
columns={COLONNES_SANS_VENTE}
|
||||
rowKey={cleLigne}
|
||||
searchIn={rechercheStock}
|
||||
searchPlaceholder="Code article ou libellé…"
|
||||
filters={FILTRES_SANS_VENTE}
|
||||
pageSize={PAGE_SIZE}
|
||||
unite="articles"
|
||||
emptyTitle="Aucun article reçu sans vente"
|
||||
emptyDescription="Tous les produits reçus et encore en stock ont été vendus au moins une fois."
|
||||
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE, rows, magasin)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "sans-vente-6mois" && (
|
||||
<DataTable<PgSansVente6MoisRow>
|
||||
rows={rowsSansVente6Mois}
|
||||
columns={COLONNES_SANS_VENTE_6MOIS}
|
||||
rowKey={cleLigne}
|
||||
searchIn={rechercheStock}
|
||||
searchPlaceholder="Code article ou libellé…"
|
||||
filters={FILTRES_SANS_VENTE_6MOIS}
|
||||
pageSize={PAGE_SIZE}
|
||||
unite="articles"
|
||||
emptyTitle="Aucun article sans vente depuis 6 mois"
|
||||
emptyDescription="Tous les produits en stock se sont vendus ou ont été réapprovisionnés ces 6 derniers mois."
|
||||
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE_6MOIS, rows, magasin)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import { PageSkeleton } from "@/components/ui/states";
|
||||
|
||||
export default function StockNegatifLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des stocks négatifs..." />;
|
||||
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
|
||||
export default function Loading() {
|
||||
return <PageSkeleton rows={10} />;
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PackageMinus, RotateCcw } from "lucide-react";
|
||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-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 { GestionStockClient } from "./client";
|
||||
|
||||
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
||||
|
||||
const SITES = [
|
||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
||||
{ code: "579", label: "579 — Houdemont" },
|
||||
];
|
||||
export const metadata: Metadata = { title: "Stocks à surveiller" };
|
||||
|
||||
interface DonneesStock {
|
||||
rowsNegatif: PgStockNegatifRow[];
|
||||
rowsSansVente: PgStockSansVenteRow[];
|
||||
rowsSansVente6Mois: PgSansVente6MoisRow[];
|
||||
}
|
||||
|
||||
export default async function GestionStockPage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
@@ -15,25 +25,52 @@ export default async function GestionStockPage(props: {
|
||||
const magasin = (searchParams.magasin as string) || "";
|
||||
const tab = (searchParams.tab as string) || "negatif";
|
||||
|
||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||
pgGetStockNegatif(magasin || undefined),
|
||||
pgGetStockSansVente(magasin || undefined),
|
||||
pgGetSansVente6Mois(magasin || undefined),
|
||||
]);
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
||||
const site = magasin || undefined;
|
||||
let donnees: DonneesStock | null = null;
|
||||
let erreur: string | null = null;
|
||||
try {
|
||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
||||
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
||||
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
||||
]);
|
||||
donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois };
|
||||
} catch (e) {
|
||||
console.error("[stock-negatif] chargement impossible :", e);
|
||||
erreur = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
params.set("tab", tab);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<PageHeader
|
||||
icon={PackageMinus}
|
||||
title="Stocks à surveiller"
|
||||
description="Les produits dont le stock pose question : stock négatif, reçus mais jamais vendus, ou sans vente depuis 6 mois. Vérifiez-les en magasin, puis régularisez le stock dans FF grâce à l'export Excel."
|
||||
/>
|
||||
{donnees ? (
|
||||
<GestionStockClient
|
||||
rowsNegatif={rowsNegatif}
|
||||
rowsSansVente={rowsSansVente}
|
||||
rowsSansVente6Mois={rowsSansVente6Mois}
|
||||
rowsNegatif={donnees.rowsNegatif}
|
||||
rowsSansVente={donnees.rowsSansVente}
|
||||
rowsSansVente6Mois={donnees.rowsSansVente6Mois}
|
||||
magasin={magasin}
|
||||
tab={tab}
|
||||
sites={SITES}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorState
|
||||
title="Les stocks n'ont pas pu être chargés"
|
||||
detail={erreur ?? undefined}
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/stock-negatif?${params.toString()}`}><RotateCcw /> Réessayer</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
interface AiConfig {
|
||||
provider: "openrouter" | "google";
|
||||
openRouterKey?: string;
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
async function getAiConfig(): Promise<AiConfig> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return {
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
|
||||
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
|
||||
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
provider: "openrouter",
|
||||
openRouterKey: process.env.OPENROUTER_API_KEY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
// ─── Tool definitions ────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS_OPENROUTER = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS_GOOGLE = [{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: Type.STRING,
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
// ─── API caller ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function callFfApi(endpoint: string): Promise<string> {
|
||||
// Sécurité : uniquement GET, uniquement les endpoints documentés
|
||||
const allowedPrefixes = [
|
||||
"/api/health", "/api/sync", "/api/schema",
|
||||
"/api/articles", "/api/stock", "/api/fournisseurs",
|
||||
"/api/commandes", "/api/mouvements", "/api/performance",
|
||||
"/api/publicites", "/api/commandes-auto",
|
||||
];
|
||||
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
|
||||
if (!isAllowed) {
|
||||
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${FF_API_BASE}${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return `Erreur API ${res.status}: ${await res.text()}`;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = JSON.stringify(data);
|
||||
|
||||
// Tronquer si trop long (max 6000 chars)
|
||||
if (text.length > 6000) {
|
||||
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
return `Erreur réseau: ${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
|
||||
|
||||
## Outil disponible
|
||||
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
|
||||
|
||||
## API FF Nancy — Documentation complète
|
||||
Base URL : ${FF_API_BASE}
|
||||
Format des dates : YYYY-MM-DD
|
||||
Identifiants articles : toujours \`no_id\` (entier interne)
|
||||
Codes site : ex 000, 001, 002…
|
||||
|
||||
### Santé / Infra
|
||||
- GET /api/health — vérifie que l'API et PostgreSQL répondent
|
||||
- GET /api/sync/status — état de la synchronisation en cours
|
||||
- GET /api/schema/tables?search=nom — liste les tables PG
|
||||
- GET /api/schema/tables/:nom — colonnes d'une table
|
||||
- GET /api/schema/top?limit=10 — tables les plus peuplées
|
||||
|
||||
### Articles
|
||||
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
|
||||
- GET /api/articles/:id — détail complet d'un article (no_id)
|
||||
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
|
||||
- GET /api/articles/:id/mouvements — historique des mouvements
|
||||
- GET /api/articles/:id/mensuel — CA mensuel de l'article
|
||||
|
||||
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
|
||||
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
|
||||
|
||||
### Stock
|
||||
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
|
||||
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
|
||||
- GET /api/stock/site/:site — tous les articles d'un site donné
|
||||
- GET /api/stock/article/:id/historique — historique stock de l'article
|
||||
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
|
||||
- GET /api/stock/valorisation — valeur totale du stock par site
|
||||
|
||||
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
|
||||
|
||||
### Fournisseurs
|
||||
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
|
||||
- GET /api/fournisseurs/:code/articles — articles du fournisseur
|
||||
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
|
||||
|
||||
### Commandes
|
||||
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
|
||||
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
|
||||
- GET /api/commandes/receptions/liste — liste des réceptions
|
||||
|
||||
### Commandes automatiques
|
||||
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
|
||||
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
|
||||
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
|
||||
|
||||
### Mouvements
|
||||
- GET /api/mouvements/types — types de mouvements disponibles
|
||||
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
|
||||
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
|
||||
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
|
||||
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
|
||||
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
|
||||
|
||||
### Performance / CA
|
||||
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
|
||||
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
|
||||
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
|
||||
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
|
||||
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
|
||||
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
|
||||
|
||||
### Publicités
|
||||
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
|
||||
- GET /api/publicites/:code?site= — détail d'une publicité
|
||||
|
||||
## Contexte métier CollectFlow
|
||||
- Application de gestion de gammes pour magasins Foirfouille Nancy
|
||||
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
|
||||
- Les ventes sont suivies par magasin (site) et par mois
|
||||
- Les utilisateurs arbitrent les classifications gamme par fournisseur
|
||||
|
||||
## Stratégie d'analyse
|
||||
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
|
||||
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
|
||||
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
|
||||
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
|
||||
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
|
||||
|
||||
## Format de réponse — IMPORTANT
|
||||
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
|
||||
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
|
||||
- Réponds toujours en français
|
||||
- Sois précis, structuré et complet — ne tronque jamais ta réponse
|
||||
- Si les données sont nombreuses, résume et mets en avant les points clés`;
|
||||
|
||||
// ─── OpenRouter handler ───────────────────────────────────────────────────────
|
||||
|
||||
async function callOpenRouter(
|
||||
messages: object[],
|
||||
model: string,
|
||||
apiKey: string,
|
||||
useTools: boolean,
|
||||
stream: boolean
|
||||
): Promise<Response> {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 8192,
|
||||
};
|
||||
if (useTools) {
|
||||
body.tools = TOOLS_OPENROUTER;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
if (stream) {
|
||||
body.stream = true;
|
||||
body.stream_options = { include_usage: true };
|
||||
}
|
||||
return fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow Admin Chat",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Google AI handler ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
|
||||
if (!config.googleAiKey) {
|
||||
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const model = config.googleAiModel ?? "gemini-2.0-flash";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
|
||||
|
||||
const contents: Array<{ role: string; parts: any[] }> = [
|
||||
...messages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
];
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const streamResponse = await ai.models.generateContentStream({
|
||||
model,
|
||||
contents: contents as any,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
tools: TOOLS_GOOGLE as any,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
let collectedText = "";
|
||||
const collectedParts: any[] = [];
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
const text = chunk.text;
|
||||
if (text) {
|
||||
collectedText += text;
|
||||
send({ type: "text", content: text });
|
||||
}
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
if (parts) collectedParts.push(...parts);
|
||||
}
|
||||
|
||||
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
|
||||
|
||||
if (fnCallParts.length === 0) break;
|
||||
|
||||
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
|
||||
|
||||
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
|
||||
for (const part of fnCallParts) {
|
||||
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
|
||||
const args = fn.args ?? {};
|
||||
send({ type: "tool_start", name: fn.name, args });
|
||||
|
||||
let result = "";
|
||||
if (fn.name === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fn.name}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResultParts.push({ functionResponse: { name: fn.name, response: { output: result } } });
|
||||
}
|
||||
|
||||
contents.push({ role: "user", parts: toolResultParts as any });
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── POST handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
|
||||
}
|
||||
|
||||
const { messages, model } = await req.json();
|
||||
const aiConfig = await getAiConfig();
|
||||
|
||||
if (aiConfig.provider === "google") {
|
||||
return handleGoogleAi(messages, aiConfig);
|
||||
}
|
||||
|
||||
const apiKey = aiConfig.openRouterKey;
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const fullMessages: object[] = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
...messages,
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
let currentMessages = [...fullMessages];
|
||||
let totalPromptTokens = 0;
|
||||
let totalCompletionTokens = 0;
|
||||
let directAnswer: string | null = null;
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
|
||||
if (!res.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.usage) {
|
||||
totalPromptTokens += data.usage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += data.usage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
const assistantMessage = data.choices?.[0]?.message;
|
||||
if (!assistantMessage) break;
|
||||
currentMessages.push(assistantMessage);
|
||||
|
||||
const toolCalls = assistantMessage.tool_calls;
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
directAnswer = assistantMessage.content ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
const toolResults: object[] = [];
|
||||
for (const tc of toolCalls) {
|
||||
const fnName = tc.function?.name;
|
||||
let args: Record<string, unknown> = {};
|
||||
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
|
||||
|
||||
send({ type: "tool_start", name: fnName, args });
|
||||
|
||||
let result = "";
|
||||
if (fnName === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fnName}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
|
||||
}
|
||||
currentMessages.push(...toolResults);
|
||||
}
|
||||
|
||||
if (directAnswer !== null) {
|
||||
send({ type: "text", content: directAnswer });
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final streaming call after tool use
|
||||
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
|
||||
if (!finalRes.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = finalRes.body?.getReader();
|
||||
if (!reader) { controller.close(); return; }
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += textDecoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(raw);
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) send({ type: "text", content: delta });
|
||||
if (chunk.usage) finalUsage = chunk.usage;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUsage) {
|
||||
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return NextResponse.json({
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterModel: config.openRouterModel,
|
||||
googleAiModel: config.googleAiModel,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ provider: "openrouter" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
|
||||
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
|
||||
// main immédiatement et le client interroge GET pour suivre l'avancement.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Préchauffage de l'instantané de grille (`grid_rows`).
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
|
||||
* verrait donc que les fournisseurs déjà consultés à la main.
|
||||
*
|
||||
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
|
||||
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
|
||||
* lourd en SQL, le paralléliser saturerait PostgreSQL.
|
||||
*/
|
||||
type WarmupStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface WarmupJob {
|
||||
status: WarmupStatus;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
let job: WarmupJob | null = null;
|
||||
|
||||
function publicJob(j: WarmupJob | null) {
|
||||
if (!j) {
|
||||
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
|
||||
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
|
||||
const existing = await listGridSuppliers();
|
||||
const freshLimit = Date.now() - staleHours * 3600_000;
|
||||
const fresh = new Set(
|
||||
existing
|
||||
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
|
||||
.map((s) => s.codeFournisseur),
|
||||
);
|
||||
|
||||
j.total = fournisseurs.length;
|
||||
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
|
||||
|
||||
for (const f of fournisseurs) {
|
||||
if (fresh.has(f.code)) {
|
||||
j.skipped++;
|
||||
continue;
|
||||
}
|
||||
j.currentFournisseur = `${f.code} — ${f.nom}`;
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
|
||||
j.done++;
|
||||
} catch (e) {
|
||||
j.failed++;
|
||||
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
|
||||
// On garde une trace des derniers échecs sans faire exploser la mémoire.
|
||||
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
|
||||
console.error("[grid-warmup]", msg);
|
||||
}
|
||||
}
|
||||
|
||||
j.currentFournisseur = undefined;
|
||||
j.status = "success";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
|
||||
} catch (e) {
|
||||
j.status = "error";
|
||||
j.error = e instanceof Error ? e.message : String(e);
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.error("[grid-warmup] échec global:", j.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/grid-warmup?staleHours=24
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
|
||||
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
|
||||
|
||||
job = {
|
||||
status: "running",
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastErrors: [],
|
||||
};
|
||||
const current = job;
|
||||
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
|
||||
|
||||
void runWarmup(current, staleHours).catch((e) => {
|
||||
current.status = "error";
|
||||
current.error = e instanceof Error ? e.message : String(e);
|
||||
current.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
|
||||
* dernière synchronisation.
|
||||
*
|
||||
* Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a
|
||||
* besoin de la vue d'ensemble pour décider qui cadencer.
|
||||
*/
|
||||
export async function GET() {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseurs: rows,
|
||||
resume: {
|
||||
total: rows.length,
|
||||
actifsSql: rows.filter((r) => r.actifSql).length,
|
||||
actifsQlik: rows.filter((r) => r.actifQlik).length,
|
||||
desactivesAuto: rows.filter((r) => r.desactiveMotif).length,
|
||||
enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length,
|
||||
enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length,
|
||||
jamaisSql: rows.filter((r) => !r.dernierSqlAt).length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const patchSchema = z.object({
|
||||
/** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */
|
||||
codes: z.array(z.string().min(1)).min(1).max(2000),
|
||||
actifSql: z.boolean().optional(),
|
||||
actifQlik: z.boolean().optional(),
|
||||
}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, {
|
||||
message: "Préciser au moins actifSql ou actifQlik",
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
|
||||
*
|
||||
* Réactiver lève le motif de désactivation automatique : sans cela, un
|
||||
* fournisseur remis en service par l'admin garderait un « aucun article »
|
||||
* trompeur, alors même qu'il vient d'être rouvert volontairement.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { codes, actifSql, actifQlik } = parsed.data;
|
||||
|
||||
const reactivation = actifSql === true || actifQlik === true;
|
||||
const rows = await db
|
||||
.update(syncFournisseurs)
|
||||
.set({
|
||||
...(actifSql !== undefined ? { actifSql } : {}),
|
||||
...(actifQlik !== undefined ? { actifQlik } : {}),
|
||||
...(reactivation ? { desactiveMotif: null } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes))
|
||||
.returning({ code: syncFournisseurs.codeFournisseur });
|
||||
|
||||
console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`);
|
||||
return NextResponse.json({ success: true, misAJour: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import {
|
||||
getSyncSchedulerState,
|
||||
lancerRound,
|
||||
demanderArret,
|
||||
debutFenetre,
|
||||
seedSyncFournisseurs,
|
||||
} from "@/features/admin/api/sync-scheduler";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
|
||||
export async function GET() {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const settings = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
const debut = debutFenetre(settings, maintenant);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settings,
|
||||
state: getSyncSchedulerState(),
|
||||
/** true = on est actuellement dans la fenêtre nocturne. */
|
||||
dansLaFenetre: debut !== null,
|
||||
fenetreDebutAt: debut?.toISOString() ?? null,
|
||||
maintenant: maintenant.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
actif: z.boolean().optional(),
|
||||
heureDebut: z.coerce.number().int().min(0).max(23).optional(),
|
||||
heureFin: z.coerce.number().int().min(0).max(23).optional(),
|
||||
qlikParNuit: z.coerce.number().int().min(0).max(200).optional(),
|
||||
sqlParQlik: z.coerce.number().int().min(1).max(500).optional(),
|
||||
qlikMinJours: z.coerce.number().int().min(0).max(365).optional(),
|
||||
});
|
||||
|
||||
/** PATCH — met à jour les réglages. */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = settingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await saveSyncSettings(parsed.data);
|
||||
return NextResponse.json({ success: true, settings });
|
||||
}
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(["start", "stop", "seed"]),
|
||||
/**
|
||||
* Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait
|
||||
* ce qu'il fait : en pleine journée, la charge est visible par les magasins.
|
||||
*/
|
||||
forcer: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = actionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsed.data.action === "seed") {
|
||||
const n = await seedSyncFournisseurs();
|
||||
return NextResponse.json({ success: true, fournisseurs: n });
|
||||
}
|
||||
|
||||
if (parsed.data.action === "stop") {
|
||||
demanderArret();
|
||||
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
|
||||
}
|
||||
|
||||
const etat = getSyncSchedulerState();
|
||||
if (etat.enCours) {
|
||||
return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 });
|
||||
}
|
||||
|
||||
// Fire-and-forget : le round dure des minutes, le client suit via GET.
|
||||
void lancerRound("manuel", parsed.data.forcer === true).catch((e) => {
|
||||
console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -9,6 +10,9 @@ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
* Transfère les query params site et codefou si présents.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -31,6 +32,9 @@ async function probe(url: string) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// Date range: 12 derniers mois
|
||||
@@ -55,7 +59,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (!firstCodein) {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
if (list.length > 0) firstCodein = (list[0] as Record<string, unknown>).codein as string ?? null;
|
||||
@@ -80,13 +84,13 @@ export async function GET(req: NextRequest) {
|
||||
referentiel_forced,
|
||||
] = await Promise.all([
|
||||
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${codefou}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
// Test mensuel avec no_id auto-résolu
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const noid = list[0] ? (list[0] as Record<string, unknown>).no_id as string : null;
|
||||
@@ -99,7 +103,7 @@ export async function GET(req: NextRequest) {
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=5`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const art = (list[4] ?? list[0]) as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
|
||||
* la page Gestion de Stock.
|
||||
*
|
||||
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
|
||||
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
|
||||
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
|
||||
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
*/
|
||||
export async function GET() {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -19,6 +20,9 @@ const ExportSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportModifiedGammesSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -13,6 +14,9 @@ const ExportModifiedGammesSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportModifiedGammesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSyncStatus } from "@/lib/api-ff-client";
|
||||
import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ff-status — état de l'API REST FF Nancy.
|
||||
*
|
||||
* S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
|
||||
* qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
|
||||
* et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const status = await getSyncStatus();
|
||||
if (!status) {
|
||||
return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 });
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const res = await diagnostiquerApiFf();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(status);
|
||||
return NextResponse.json(res.status);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedGoogleKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.googleAiKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-google-ai-key")
|
||||
|| process.env.GOOGLE_AI_KEY
|
||||
|| await getSavedGoogleKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return NextResponse.json({ error: err }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Keep only generative models that support generateContent
|
||||
const models = (data.models as Array<{
|
||||
name: string;
|
||||
displayName: string;
|
||||
supportedGenerationMethods?: string[];
|
||||
description?: string;
|
||||
}>)
|
||||
.filter(m =>
|
||||
m.supportedGenerationMethods?.includes("generateContent") &&
|
||||
!m.name.includes("embedding") &&
|
||||
!m.name.includes("aqa")
|
||||
)
|
||||
.map(m => ({
|
||||
id: m.name.replace("models/", ""),
|
||||
name: m.displayName,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "fr"));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
|
||||
controller.enqueue(new TextEncoder().encode(`${JSON.stringify(value)}\n`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const codeFournisseur = searchParams.get("fournisseur");
|
||||
|
||||
@@ -18,21 +24,33 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const magasin = searchParams.get("magasin") ?? "TOTAL";
|
||||
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
|
||||
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
|
||||
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
|
||||
const filters: Partial<GridFilters> = {
|
||||
code1: searchParams.get("code1"),
|
||||
code2: searchParams.get("code2"),
|
||||
code3: searchParams.get("code3"),
|
||||
};
|
||||
const forceRefresh = searchParams.get("refresh") === "1";
|
||||
|
||||
// Client parti (changement de fournisseur, page quittée) : on arrête de
|
||||
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
|
||||
// mis en cache, la prochaine ouverture en profitera.
|
||||
let cancelled = false;
|
||||
request.signal.addEventListener("abort", () => { cancelled = true; });
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
async start(controller) {
|
||||
try {
|
||||
writeNdjson(controller, { type: "start", loaded: 0, total: null });
|
||||
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
|
||||
if (cancelled) return;
|
||||
const total = rows.length;
|
||||
|
||||
for (let start = 0; start < total; start += chunkSize) {
|
||||
if (cancelled) return;
|
||||
const chunk = rows.slice(start, start + chunkSize);
|
||||
writeNdjson(controller, {
|
||||
type: "chunk",
|
||||
@@ -45,12 +63,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
writeNdjson(controller, { type: "done", loaded: total, total });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
writeNdjson(controller, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* flux déjà fermé côté client */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getStorePatch } from "@/features/grid/api/get-product-rows";
|
||||
import { MAGASINS } from "@/lib/magasins";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
|
||||
*
|
||||
* Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
|
||||
* La Grille charge toujours les lignes « tous magasins » et applique ce
|
||||
* complément à son arrivée : le changement de magasin n'attend plus rien.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
|
||||
const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
|
||||
// Un code inconnu ferait de chaque article un candidat : autant d'appels
|
||||
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
|
||||
if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
|
||||
return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const patch = await getStorePatch(codeFournisseur, magasin);
|
||||
return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
|
||||
return Response.json({ error: "Complément indisponible." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,15 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
const refus = await adminOuReponse();
|
||||
if (refus instanceof Response) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedOpenRouterKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.openRouterKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-openrouter-key")
|
||||
|| process.env.OPENROUTER_API_KEY
|
||||
|| await getSavedOpenRouterKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Return simplified list sorted by name
|
||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
|
||||
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
|
||||
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Free models first, then alphabetical
|
||||
if (a.free && !b.free) return -1;
|
||||
if (!a.free && b.free) return 1;
|
||||
return a.name.localeCompare(b.name, "fr");
|
||||
});
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
@@ -12,14 +12,10 @@ export const dynamic = "force-dynamic";
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
@@ -21,23 +21,18 @@ export const dynamic = "force-dynamic";
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
* Contrôle de session explicite en plus du middleware : il répond ici en JSON,
|
||||
* et relit le compte en base (cf. lib/authz).
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
@@ -54,8 +49,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
@@ -121,34 +121,6 @@ function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||
*/
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
@@ -337,8 +309,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
@@ -347,8 +319,8 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
@@ -371,8 +343,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
@@ -414,8 +386,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
|
||||
import { adminOuReponse } from '@/lib/authz';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const codein = searchParams.get('codein') || '334152';
|
||||
@@ -24,7 +28,9 @@ export async function GET(request: Request) {
|
||||
stock: Object.fromEntries(map),
|
||||
fou: fouResult.rows,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message, stack: e.stack }, { status: 500 });
|
||||
} catch (e) {
|
||||
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
|
||||
console.error('[api/test-db]', e);
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { fournisseursQuerySchema } from "@/lib/api-schemas";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/fournisseurs?search=…&withData=1
|
||||
*
|
||||
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
|
||||
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
|
||||
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
|
||||
*
|
||||
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const { search, withData } = parsed.data;
|
||||
|
||||
const snapshot = await listGridSuppliers();
|
||||
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
|
||||
|
||||
if (withData === "1") {
|
||||
const term = search?.toLowerCase();
|
||||
const data = snapshot
|
||||
.filter((s) => !term
|
||||
|| s.codeFournisseur.toLowerCase().includes(term)
|
||||
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
|
||||
.map((s) => ({
|
||||
code: s.codeFournisseur,
|
||||
nom: s.nomFournisseur,
|
||||
hasSnapshot: true,
|
||||
rowCount: s.rowCount,
|
||||
computedAt: s.computedAt,
|
||||
}));
|
||||
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
|
||||
}
|
||||
|
||||
const fournisseurs = await pgGetFournisseurs(search);
|
||||
const data = fournisseurs.map((f) => {
|
||||
const snap = byCode.get(f.code);
|
||||
return {
|
||||
code: f.code,
|
||||
nom: f.nom,
|
||||
hasSnapshot: Boolean(snap),
|
||||
rowCount: snap?.rowCount ?? 0,
|
||||
computedAt: snap?.computedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
|
||||
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
|
||||
*
|
||||
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
|
||||
* l'instantané persisté (`grid_rows`).
|
||||
*
|
||||
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
|
||||
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
|
||||
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
|
||||
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
|
||||
* au passage — les appels suivants repassent donc par le chemin rapide.
|
||||
*
|
||||
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
|
||||
let freshness = await getGridFreshness(q.fournisseur);
|
||||
let computedOnDemand = false;
|
||||
|
||||
if (!freshness) {
|
||||
if (q.compute === "0") {
|
||||
return fail(
|
||||
"not_ready",
|
||||
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
|
||||
try {
|
||||
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
|
||||
// et ProductRow embarque déjà les ventilations par magasin.
|
||||
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
|
||||
return fail(
|
||||
"internal_error",
|
||||
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
freshness = await getGridFreshness(q.fournisseur);
|
||||
if (!freshness) {
|
||||
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
|
||||
// il n'a aucun article. À distinguer d'une panne.
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
computedOnDemand = true;
|
||||
}
|
||||
|
||||
const filtres = {
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
code1: q.code1,
|
||||
code2: q.code2,
|
||||
code3: q.code3,
|
||||
nomenclature: q.nomenclature,
|
||||
search: q.search,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
};
|
||||
|
||||
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
|
||||
// comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait
|
||||
// exploser la mémoire du processus : on diffuse par lots, à mémoire constante.
|
||||
if (q.limit == null) {
|
||||
return streamAllRows(req, filtres, q, freshness, computedOnDemand);
|
||||
}
|
||||
|
||||
const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit });
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
const pagination = buildPagination(q.page, q.limit, result.total);
|
||||
|
||||
return ok(
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination,
|
||||
meta: {
|
||||
fournisseur: q.fournisseur,
|
||||
computedAt: result.computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
|
||||
computedOnDemand,
|
||||
/**
|
||||
* true = cette réponse contient **toutes** les lignes du fournisseur.
|
||||
* Dit explicitement à un appelant — en particulier un agent — s'il
|
||||
* peut s'arrêter là, au lieu de le laisser déduire d'une pagination
|
||||
* qu'il risque d'ignorer.
|
||||
*/
|
||||
complet: !pagination.hasMore,
|
||||
...(pagination.hasMore
|
||||
? {
|
||||
avertissement:
|
||||
`Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. `
|
||||
+ `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */
|
||||
const STREAM_BATCH = 1000;
|
||||
|
||||
type Filtres = Parameters<typeof queryGridRows>[0];
|
||||
|
||||
/**
|
||||
* Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
|
||||
* jamais la construire entièrement en mémoire.
|
||||
*
|
||||
* La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un
|
||||
* client existant ne voit aucune différence, il reçoit simplement les octets au
|
||||
* fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total
|
||||
* connu — l'ordre des clés n'a aucune importance en JSON.
|
||||
*
|
||||
* Une erreur survenant après les premiers octets ne peut plus changer le code
|
||||
* HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant
|
||||
* détecte une réponse tronquée au lieu de la croire complète.
|
||||
*/
|
||||
function streamAllRows(
|
||||
req: NextRequest,
|
||||
filtres: Filtres,
|
||||
q: { enrich: string; fields?: string; fournisseur: string },
|
||||
freshness: { rowCount: number; computedAt: string | null },
|
||||
computedOnDemand: boolean,
|
||||
): Response {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const write = (s: string) => controller.enqueue(encoder.encode(s));
|
||||
let envoyees = 0;
|
||||
let total = 0;
|
||||
let computedAt: string | null = null;
|
||||
let erreur: string | null = null;
|
||||
|
||||
write('{"data":[');
|
||||
try {
|
||||
for (let page = 1; ; page++) {
|
||||
const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH });
|
||||
if (page === 1) {
|
||||
total = lot.total;
|
||||
computedAt = lot.computedAt;
|
||||
}
|
||||
if (lot.rows.length === 0) break;
|
||||
|
||||
const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows;
|
||||
for (const r of enrichies) {
|
||||
write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields)));
|
||||
envoyees++;
|
||||
}
|
||||
if (lot.rows.length < STREAM_BATCH) break;
|
||||
}
|
||||
} catch (e) {
|
||||
erreur = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur);
|
||||
}
|
||||
|
||||
const complet = erreur === null && envoyees === total;
|
||||
write("]," + JSON.stringify({
|
||||
pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet },
|
||||
meta: {
|
||||
fournisseur: q.fournisseur,
|
||||
computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
computedOnDemand,
|
||||
diffuse: true,
|
||||
complet,
|
||||
...(erreur
|
||||
? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` }
|
||||
: {}),
|
||||
},
|
||||
}).slice(1));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/network/:codeCentrale
|
||||
*
|
||||
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
|
||||
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
|
||||
*
|
||||
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
|
||||
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codeCentrale: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codeCentrale } = await ctx.params;
|
||||
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
|
||||
const found = metrics.get(codeCentrale);
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
|
||||
);
|
||||
}
|
||||
|
||||
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
|
||||
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
|
||||
*
|
||||
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
|
||||
* nombre d'articles et le CA de chacun.
|
||||
*
|
||||
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
|
||||
* 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
|
||||
* pour une application. On liste d'abord les postes — quelques dizaines de
|
||||
* lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
|
||||
*
|
||||
* Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
|
||||
* niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
|
||||
* un préfixe pour descendre l'arborescence sans tout charger.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const { fournisseur, niveau, parent } = parsed.data;
|
||||
|
||||
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
|
||||
const freshness = await getGridFreshness(fournisseur);
|
||||
if (!freshness) {
|
||||
return fail(
|
||||
"not_ready",
|
||||
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
|
||||
{ fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
|
||||
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
|
||||
|
||||
return ok(postes, {
|
||||
meta: {
|
||||
fournisseur,
|
||||
niveau,
|
||||
parent: parent ?? null,
|
||||
nbPostes: postes.length,
|
||||
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
|
||||
nbArticles,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
/**
|
||||
* Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
|
||||
* `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
|
||||
*/
|
||||
filtreGrid: "nomenclature",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
|
||||
*
|
||||
* **Volontairement accessible sans authentification.** Le document ne décrit que la
|
||||
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
|
||||
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
|
||||
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
|
||||
* impossible.
|
||||
*
|
||||
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
|
||||
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
||||
const origin = configured || req.nextUrl.origin;
|
||||
|
||||
const paginationParams = [
|
||||
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." },
|
||||
];
|
||||
const sortParams = [
|
||||
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
|
||||
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
|
||||
{
|
||||
name: "fields",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
|
||||
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
|
||||
+ "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
|
||||
},
|
||||
{
|
||||
name: "enrich",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
|
||||
+ "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
|
||||
},
|
||||
];
|
||||
|
||||
const spec = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "CollectFlow API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
|
||||
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
|
||||
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
|
||||
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
|
||||
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
|
||||
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
|
||||
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
|
||||
+ "alimenté par une synchronisation séparée.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
|
||||
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
|
||||
+ "(`codeGammeServeur`).",
|
||||
},
|
||||
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: {
|
||||
type: "string",
|
||||
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
|
||||
},
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Pagination: {
|
||||
type: "object",
|
||||
properties: {
|
||||
page: { type: "integer" },
|
||||
limit: { type: "integer" },
|
||||
total: { type: "integer" },
|
||||
totalPages: { type: "integer" },
|
||||
hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." },
|
||||
},
|
||||
},
|
||||
Fournisseur: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
|
||||
nom: { type: ["string", "null"] },
|
||||
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
|
||||
rowCount: { type: "integer" },
|
||||
computedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
NetworkMetrics: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
|
||||
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
|
||||
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
Product: {
|
||||
type: "object",
|
||||
description:
|
||||
"Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous "
|
||||
+ "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine "
|
||||
+ "de champs). Utilisez `fields` pour n'en garder qu'une partie.",
|
||||
properties: {
|
||||
codein: { type: "string", description: "Identifiant interne de l'article." },
|
||||
codeFournisseur: { type: "string" },
|
||||
nomFournisseur: { type: "string" },
|
||||
libelle1: { type: "string", description: "Libellé de l'article." },
|
||||
gtin: { type: "string" },
|
||||
reference: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
|
||||
codeGammeServeur: {
|
||||
type: ["string", "null"],
|
||||
description:
|
||||
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
|
||||
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
|
||||
+ "C (performance), D (saisonnier/niche), Z (sortie).",
|
||||
},
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
|
||||
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
tauxMarge: { type: "number" },
|
||||
stockActuel: { type: "number" },
|
||||
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
|
||||
prixAchat: { type: "number" },
|
||||
prixVente: { type: "number" },
|
||||
derniereVente: { type: "string", format: "date" },
|
||||
nbJoursDerniereVente: { type: "integer" },
|
||||
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
|
||||
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
|
||||
receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." },
|
||||
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
|
||||
stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." },
|
||||
receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." },
|
||||
qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." },
|
||||
network: { $ref: "#/components/schemas/NetworkMetrics" },
|
||||
trend: { $ref: "#/components/schemas/Trend" },
|
||||
},
|
||||
},
|
||||
Trend: {
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
"Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne "
|
||||
+ "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.",
|
||||
properties: {
|
||||
direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." },
|
||||
pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." },
|
||||
label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" },
|
||||
monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/fournisseurs": {
|
||||
get: {
|
||||
operationId: "listerFournisseurs",
|
||||
summary: "Lister les fournisseurs",
|
||||
description:
|
||||
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
|
||||
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
|
||||
parameters: [
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
|
||||
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Liste des fournisseurs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/nomenclatures": {
|
||||
get: {
|
||||
operationId: "listerNomenclaturesFournisseur",
|
||||
summary: "Postes de nomenclature d'un fournisseur",
|
||||
description:
|
||||
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
|
||||
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
|
||||
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
|
||||
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
|
||||
+ "(code1, code2 ou code3).",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." },
|
||||
{ name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Postes de nomenclature",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
|
||||
libelle: { type: ["string", "null"] },
|
||||
nbArticles: { type: "integer" },
|
||||
totalCa: { type: "number" },
|
||||
totalQuantite: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/grid": {
|
||||
get: {
|
||||
operationId: "listerProduitsFournisseur",
|
||||
summary: "Lister les produits d'un fournisseur",
|
||||
description:
|
||||
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
|
||||
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
|
||||
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
|
||||
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
|
||||
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
|
||||
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
|
||||
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
|
||||
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
|
||||
{ name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." },
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
|
||||
{
|
||||
name: "compute",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
|
||||
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
|
||||
},
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits du fournisseur",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/search": {
|
||||
get: {
|
||||
operationId: "rechercherProduits",
|
||||
summary: "Rechercher des produits, tous fournisseurs confondus",
|
||||
description:
|
||||
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
|
||||
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits correspondants",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/{codein}": {
|
||||
get: {
|
||||
operationId: "obtenirProduit",
|
||||
summary: "Fiche complète d'un produit",
|
||||
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
|
||||
parameters: [
|
||||
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Fiche produit",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/network/{codeCentrale}": {
|
||||
get: {
|
||||
operationId: "obtenirMetriquesReseau",
|
||||
summary: "Métriques du réseau Qlik pour un produit",
|
||||
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
|
||||
parameters: [
|
||||
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Métriques réseau",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
|
||||
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { productDetailSchema } from "@/lib/api-schemas";
|
||||
import { getGridRowByCodein } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:codein?fournisseur=…
|
||||
*
|
||||
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
|
||||
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
|
||||
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
|
||||
*
|
||||
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
|
||||
* sans lui, l'instantané le plus récent est renvoyé.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codein: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codein } = await ctx.params;
|
||||
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
|
||||
|
||||
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
|
||||
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
|
||||
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
|
||||
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
|
||||
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
|
||||
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
|
||||
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
} catch (e) {
|
||||
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
parsed.data.fournisseur
|
||||
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
|
||||
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
|
||||
// calcul de la grille, alors que ces deux données évoluent indépendamment.
|
||||
const [enriched] = await enrichRows([found.row]);
|
||||
|
||||
return ok(enriched, {
|
||||
meta: {
|
||||
computedAt: found.computedAt,
|
||||
networkFetchedAt: enriched.network?.fetchedAt ?? null,
|
||||
hasNetwork: enriched.network !== null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
|
||||
*
|
||||
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
|
||||
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
|
||||
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
|
||||
* était impossible.
|
||||
*
|
||||
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
|
||||
* Grille n'apparaît pas encore dans les résultats.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
const result = await queryGridRows({
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
search: q.q,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
page: q.page,
|
||||
limit: q.limit,
|
||||
});
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
return ok(
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination: buildPagination(q.page, q.limit, result.total),
|
||||
meta: {
|
||||
query: q.q,
|
||||
computedAt: result.computedAt,
|
||||
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
|
||||
enrichi: q.enrich === "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+91
-9
@@ -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 <html>), 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 <link> 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 ── */
|
||||
@@ -238,6 +279,47 @@
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Défilement de la Grille ──
|
||||
La règle ci-dessus (8 px, rail transparent, pouce très pâle) convient aux
|
||||
panneaux où l'ascenseur est accessoire. Dans la Grille, le tableau fait
|
||||
~2600 px de large : l'ascenseur HORIZONTAL est l'outil de navigation
|
||||
principal, et un rail invisible ne dit pas qu'il reste des colonnes à
|
||||
droite. On lui donne donc un rail dessiné et un pouce saisissable.
|
||||
`scrollbar-*` couvre Firefox, que les sélecteurs -webkit- ignorent. */
|
||||
.grid-scroll {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--text-muted) var(--bg-elevated);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-track:horizontal {
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 99px;
|
||||
/* Bordure transparente + background-clip : le pouce garde de l'air dans son
|
||||
rail sans qu'on ait à jouer sur des marges, impossibles ici. */
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Le coin entre les deux ascenseurs, sinon laissé en blanc par le navigateur. */
|
||||
.grid-scroll::-webkit-scrollbar-corner {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ── Heatmap cells ── */
|
||||
.heatmap-zero {
|
||||
background: var(--bg-elevated);
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex min-h-screen items-center justify-center p-6 bg-[var(--bg-base)]">
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="Page introuvable"
|
||||
description="L'adresse demandée n'existe pas ou n'existe plus."
|
||||
action={<Button asChild><Link href="/dashboard">Retour à l'accueil</Link></Button>}
|
||||
className="max-w-lg"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SessionProvider>
|
||||
// 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.
|
||||
<SessionProvider refetchOnWindowFocus={false}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<FeedbackHost />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, KeyRound, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (newPassword: string) => Promise<void>;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
username,
|
||||
}: ChangePasswordModalProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setError(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < 4) {
|
||||
setError("Le mot de passe doit faire au moins 4 caractères.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Les mots de passe ne correspondent pas.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onConfirm(password);
|
||||
handleClose();
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "Une erreur est survenue.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen && !isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{ background: "rgba(0,0,0,0.55)" }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="p-7">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-7">
|
||||
<div className="w-11 h-11 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center shrink-0">
|
||||
<KeyRound className="w-5 h-5 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[var(--text-primary)]">Changer le mot de passe</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">Compte : <span className="font-semibold text-[var(--text-secondary)]">{username}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* New password */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 4 caractères"
|
||||
className="apple-input pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="Répétez le mot de passe"
|
||||
className="apple-input pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password match indicator */}
|
||||
{confirm.length > 0 && (
|
||||
<div className={`flex items-center gap-2 text-xs font-semibold ${password === confirm ? "text-[var(--accent-success)]" : "text-[var(--accent-error)]"}`}>
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
{password === confirm ? "Les mots de passe correspondent" : "Les mots de passe ne correspondent pas"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-3 py-2.5 rounded-lg bg-[var(--accent-error-bg)] border border-[var(--accent-error)] text-[var(--accent-error)] text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="apple-btn-secondary flex-1 justify-center"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || password !== confirm || password.length < 4}
|
||||
className="apple-btn-primary flex-1 justify-center disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <KeyRound className="w-4 h-4" />}
|
||||
{isLoading ? "Mise à jour..." : "Valider"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(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 (
|
||||
<div
|
||||
className={`fixed inset-0 z-[200] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{ background: "rgba(0,0,0,0.55)" }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-0.5"
|
||||
style={{
|
||||
background: isDanger
|
||||
? "linear-gradient(to right, var(--accent-error), #fb7185)"
|
||||
: "linear-gradient(to right, var(--accent), var(--accent-success))"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="p-7">
|
||||
{/* Icon + Title */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isDanger ? "bg-[var(--accent-error-bg)] border border-[var(--accent-error)]" : "bg-[var(--accent-bg)] border border-[var(--accent-border)]"}`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${isDanger ? "text-[var(--accent-error)]" : "text-[var(--accent)]"}`} />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-[var(--text-primary)]">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-7 leading-relaxed pl-[52px]">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="apple-btn-secondary"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmBtnRef}
|
||||
onClick={handleConfirm}
|
||||
className={isDanger ? "apple-btn-danger" : "apple-btn-primary"}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<header
|
||||
className="h-12 flex items-center justify-between px-5 sticky top-0 z-20 glass"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="relative w-72">
|
||||
<Search
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
value={search}
|
||||
onChange={(e) => setFilter("search", e.target.value)}
|
||||
className="apple-input pr-8 h-8 text-[13px]"
|
||||
style={{ paddingLeft: "32px" }}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setFilter("search", "")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-3 h-3" style={{ color: "var(--text-muted)" }} />
|
||||
</button>
|
||||
<header className="h-14 shrink-0 flex items-center justify-between gap-4 px-6 glass border-b border-[var(--border)]">
|
||||
<div className="min-w-0">
|
||||
{page && (
|
||||
<p className="truncate text-sm font-semibold text-[var(--text-primary)]" title={page.description}>
|
||||
{page.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Mode clair" : "Mode sombre"}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all duration-150"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
className="flex h-9 items-center gap-2 rounded-lg px-3 text-[13px] font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
title={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
>
|
||||
{isDark
|
||||
? <Sun className="w-[15px] h-[15px]" strokeWidth={1.8} />
|
||||
: <Moon className="w-[15px] h-[15px]" strokeWidth={1.8} />}
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="hidden md:inline">{isDark ? "Mode clair" : "Mode sombre"}</span>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-5 mx-1" style={{ background: "var(--border)" }} />
|
||||
|
||||
{/* User */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[26px] h-[26px] rounded-full bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center text-[11px] font-bold text-white shadow-sm border border-white/20">
|
||||
M
|
||||
</div>
|
||||
<span className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Michael
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 rounded-lg pl-1.5 pr-2 hover:bg-[var(--bg-elevated)]"
|
||||
aria-label="Menu du compte"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent)] text-xs font-bold text-white">
|
||||
{session ? initiale : <UserIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="hidden sm:block max-w-[160px] truncate text-[13px] font-medium text-[var(--text-primary)]">
|
||||
{nom}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>
|
||||
<span className="block text-sm font-semibold text-[var(--text-primary)]">{nom || "Compte"}</span>
|
||||
<span className="block text-xs font-normal text-[var(--text-muted)]">{role}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-medium text-[var(--text-secondary)]">
|
||||
<Rows3 className="h-3.5 w-3.5" /> Hauteur des lignes de la Grille
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={displayDensity}
|
||||
onValueChange={(v) => setDisplayDensity(v as Densite)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="compact">Compacte</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="normal">Normale</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="comfortable">Aérée</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
<LogOut /> Se déconnecter
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
interface LoadingModalProps {
|
||||
message?: string;
|
||||
subMessage?: string;
|
||||
}
|
||||
|
||||
export default function LoadingModal({
|
||||
message = "Chargement des données",
|
||||
subMessage = "Veuillez patienter...",
|
||||
}: LoadingModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div
|
||||
className="flex flex-col items-center gap-5 px-10 py-8 rounded-2xl shadow-2xl border"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="relative w-12 h-12">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-[3px] animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
borderTopColor: "var(--accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className="text-sm font-semibold tracking-wide"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{subMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, MessageSquareCode } 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: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", 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 (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-all duration-300 relative",
|
||||
isExpanded ? "w-60" : "w-16 items-center"
|
||||
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-[width] duration-200 relative border-r border-[var(--border)]",
|
||||
isExpanded ? "w-[280px]" : "w-16",
|
||||
)}
|
||||
style={{ borderRight: "1px solid var(--border)" }}
|
||||
aria-label="Menu principal"
|
||||
>
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="absolute -right-3 top-6 w-6 h-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-full flex items-center justify-center shadow-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-300 transition-colors z-40"
|
||||
aria-label={isExpanded ? "Replier le menu" : "Déplier le menu"}
|
||||
title={isExpanded ? "Replier le menu" : "Déplier le menu"}
|
||||
className="absolute -right-3 top-5 z-40 flex h-6 w-6 items-center justify-center rounded-full border shadow-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{isExpanded ? <ChevronLeft className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
{isExpanded ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className={cn("py-[18px] w-full flex", isExpanded ? "px-5" : "justify-center")} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-[var(--accent)] flex items-center justify-center shrink-0 shadow-sm border border-white/10" title="CollectFlow">
|
||||
<Package className="w-[18px] h-[18px] text-white" strokeWidth={2.2} />
|
||||
<div className={cn("flex h-14 items-center border-b border-[var(--border)]", isExpanded ? "px-5" : "justify-center")}>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" title="CollectFlow — accueil">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--accent)] shadow-sm">
|
||||
<Package className="h-[18px] w-[18px] text-white" strokeWidth={2.2} />
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<span className="text-[15px] font-semibold tracking-[-0.3px] text-[var(--text-primary)] whitespace-nowrap overflow-hidden">
|
||||
CollectFlow
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && <span className="text-base font-semibold text-[var(--text-primary)]">CollectFlow</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={cn("w-full flex-1 flex flex-col py-4 space-y-2", isExpanded ? "px-2" : "items-center px-0")}>
|
||||
{filteredItems.map(({ icon: Icon, label, href }) => {
|
||||
const isActive = pathname.startsWith(href);
|
||||
|
||||
let resolvedHref = href;
|
||||
if (href === "/grid" && isMounted && activeGridQuery) {
|
||||
resolvedHref = `/grid${activeGridQuery}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={resolvedHref}
|
||||
title={!isExpanded ? label : undefined}
|
||||
style={{
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
background: isActive ? "var(--bg-elevated)" : "transparent",
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center rounded-xl transition-all duration-150 overflow-hidden",
|
||||
isExpanded ? "px-3 py-[7px] gap-2.5 w-full" : "justify-center w-10 h-10",
|
||||
!isActive && "hover:bg-[var(--bg-elevated)]"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
|
||||
{isExpanded && <span className="text-[13px] font-medium whitespace-nowrap">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Profile & Logout */}
|
||||
<div className={cn("w-full pb-4 flex flex-col gap-3", isExpanded ? "px-3" : "items-center")}>
|
||||
<div className={cn(
|
||||
"rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] flex items-center shadow-sm overflow-hidden",
|
||||
isExpanded ? "px-3 py-3 gap-3 w-full" : "justify-center w-10 h-10"
|
||||
)}
|
||||
title={!isExpanded ? (session?.user?.name || "Profil") : undefined}
|
||||
>
|
||||
<div className={cn(
|
||||
"rounded-lg bg-[var(--accent-bg)] flex items-center justify-center border border-[var(--accent-border)] shrink-0",
|
||||
isExpanded ? "w-8 h-8" : "w-6 h-6 border-transparent bg-transparent"
|
||||
)}>
|
||||
<UserIcon className={cn("text-[var(--accent)]", isExpanded ? "w-4 h-4" : "w-5 h-5")} />
|
||||
<nav className={cn("flex-1 overflow-y-auto py-3 px-2")}>
|
||||
{groupes.map((groupe, gi) => (
|
||||
<div key={groupe.title} className={cn(gi > 0 && "mt-4")}>
|
||||
{isExpanded ? (
|
||||
<p className="mb-1 px-3 text-xs font-semibold text-[var(--text-muted)]">{groupe.title}</p>
|
||||
) : (
|
||||
gi > 0 && <div className="mx-2 mb-3 border-t border-[var(--border)]" aria-hidden />
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{groupe.items.map(({ icon: Icon, label, href, description }) => {
|
||||
const isActive = pathname === href || pathname.startsWith(`${href}/`);
|
||||
// Retour à la Grille : on rouvre le dernier fournisseur consulté.
|
||||
const resolvedHref = href === "/grid" && isMounted && activeGridQuery
|
||||
? `/grid${activeGridQuery}`
|
||||
: href;
|
||||
const lien = (
|
||||
<Link
|
||||
href={resolvedHref}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
isExpanded ? "gap-3 px-3 py-2" : "h-10 w-10 justify-center mx-auto",
|
||||
isActive
|
||||
? "bg-[var(--accent-bg)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-[18px] w-[18px] shrink-0", isActive && "text-[var(--accent)]")}
|
||||
strokeWidth={1.9}
|
||||
aria-hidden
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<span className="truncate text-sm">{label}</span>
|
||||
) : (
|
||||
<span className="sr-only">{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<li key={href}>
|
||||
{isExpanded ? lien : (
|
||||
<Tooltip
|
||||
side="right"
|
||||
content={<><span className="font-semibold">{label}</span><span className="block text-[var(--text-secondary)]">{description}</span></>}
|
||||
>
|
||||
{lien}
|
||||
</Tooltip>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex-1 min-w-0">
|
||||
{session?.user ? (
|
||||
<>
|
||||
<p className="text-[12px] font-bold text-[var(--text-primary)] truncate">{session.user.name}</p>
|
||||
<p className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
|
||||
{userRole === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-[var(--accent)]" />
|
||||
<p className="text-[10px] text-[var(--text-muted)] animate-pulse">Session...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
title={!isExpanded ? "Déconnexion" : undefined}
|
||||
className={cn(
|
||||
"flex items-center text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] transition-colors rounded-xl overflow-hidden",
|
||||
isExpanded ? "w-full gap-2.5 px-3 py-2 text-[13px] font-medium" : "justify-center w-10 h-10"
|
||||
)}
|
||||
>
|
||||
<LogOut className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
|
||||
{isExpanded && "Déconnexion"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
interface SuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
/**
|
||||
* `error` change l'icône, l'accent et **désactive la fermeture automatique** :
|
||||
* un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit
|
||||
* pas s'afficher sous une coche verte de succès.
|
||||
*/
|
||||
variant?: "success" | "error";
|
||||
/** Action secondaire optionnelle (ex. « Recharger la page »). */
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */
|
||||
const EXIT_MS = 200;
|
||||
|
||||
export function SuccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
duration = 3000,
|
||||
variant = "success",
|
||||
action,
|
||||
}: SuccessModalProps) {
|
||||
// Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS
|
||||
// au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet.
|
||||
const [closing, setClosing] = useState(false);
|
||||
const isError = variant === "error";
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setClosing(false);
|
||||
onClose();
|
||||
}, EXIT_MS);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
// Une erreur ne disparaît pas toute seule : l'utilisateur la ferme.
|
||||
if (!isOpen || isError) return;
|
||||
const timer = setTimeout(() => handleClose(), duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, duration, isError, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const state = closing ? "closed" : "open";
|
||||
const Icon = isError ? AlertCircle : CheckCircle2;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-sm duration-200 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{ background: "rgba(0,0,0,0.45)" }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
data-state={state}
|
||||
className="relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl duration-200 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-0.5"
|
||||
style={{
|
||||
background: isError
|
||||
? "var(--accent-error)"
|
||||
: "linear-gradient(to right, var(--accent), var(--accent-success))",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="p-8 flex flex-col items-center text-center">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
|
||||
style={
|
||||
isError
|
||||
? { background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)" }
|
||||
: { background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }
|
||||
}
|
||||
>
|
||||
<Icon className="w-8 h-8" style={{ color: isError ? "var(--accent-error)" : "var(--accent)" }} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-medium leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{action && (
|
||||
<button onClick={action.onClick} className="apple-btn-primary mt-7 w-full justify-center">
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`${action ? "apple-btn-secondary mt-2" : "apple-btn-primary mt-7"} w-full justify-center`}
|
||||
>
|
||||
{action ? "Plus tard" : "Continuer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
storageKey="collectflow-theme"
|
||||
>
|
||||
|
||||
@@ -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<Ton, string> = {
|
||||
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 (
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-semibold",
|
||||
TONS[ton],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* É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 <Badge className={className} title="Pas de comparaison possible">—</Badge>;
|
||||
}
|
||||
const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre";
|
||||
const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus;
|
||||
return (
|
||||
<Badge ton={ton} className={cn("tabular-nums", className)}>
|
||||
<Icone className="h-3.5 w-3.5" aria-hidden />
|
||||
{pct > 0 ? "+" : ""}{fmtDecimal1(pct)} %
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nom du magasin, jamais son code brut. */
|
||||
export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) {
|
||||
return <Badge ton="accent">{nomMagasin(code, { court })}</Badge>;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
title={g ? `${g.code} — ${g.nom} : ${g.description}` : "Aucune gamme"}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-bold",
|
||||
g ? g.classes : CLASSES_SANS_GAMME,
|
||||
)}
|
||||
>
|
||||
{g ? g.code : "—"}
|
||||
{avecNom && <span className="font-medium">{g ? g.nom : "Sans gamme"}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-xl border bg-[var(--bg-surface)] border-[var(--border)] shadow-[var(--shadow-sm)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, description, actions, className }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 px-5 pt-4 pb-3 border-b border-[var(--border)]", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-[13px] text-[var(--text-secondary)]">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */
|
||||
export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className="text-xs font-medium text-[var(--text-muted)]">{label}</div>
|
||||
<div className="mt-0.5 text-sm text-[var(--text-primary)] break-words">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<T> {
|
||||
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<T> {
|
||||
rows: readonly T[];
|
||||
columns: readonly DataColumn<T>[];
|
||||
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<T>[];
|
||||
/** 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> | 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<T>({
|
||||
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<T>) {
|
||||
const [recherche, setRecherche] = useState("");
|
||||
const rechercheDifferee = useDeferredValue(recherche);
|
||||
const [valeursFiltres, setValeursFiltres] = useState<Record<string, string>>({});
|
||||
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<string, { value: string; label: string }[]> = {};
|
||||
for (const f of filters) {
|
||||
const valeurs = new Set<string>();
|
||||
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 (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Barre d'outils */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{toolbar}
|
||||
{searchIn && (
|
||||
<SearchInput value={recherche} onChange={setRecherche} placeholder={searchPlaceholder} aria-label="Rechercher dans le tableau" />
|
||||
)}
|
||||
{filters.map((f) => (
|
||||
<Select
|
||||
key={f.id}
|
||||
aria-label={f.label}
|
||||
label={f.label}
|
||||
value={valeursFiltres[f.id] ?? ""}
|
||||
onChange={(v) => setValeursFiltres((s) => ({ ...s, [f.id]: v }))}
|
||||
placeholder={f.allLabel ?? "Tous"}
|
||||
options={optionsFiltres[f.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
{filtreActif && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => { setRecherche(""); setValeursFiltres({}); }}
|
||||
>
|
||||
<RotateCcw /> Effacer les filtres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[13px] text-[var(--text-secondary)] tabular-nums">
|
||||
<span className="font-semibold text-[var(--text-primary)]">{total.toLocaleString("fr-FR")}</span> {unite}
|
||||
{filtreActif && total !== rows.length && <> sur {rows.length.toLocaleString("fr-FR")}</>}
|
||||
</span>
|
||||
{onExport && (
|
||||
<Button variant="outline" onClick={exporter} disabled={exportEnCours || total === 0}>
|
||||
{exportEnCours ? <Loader2 className="animate-spin" /> : <Download />}
|
||||
{exportEnCours ? "Export en cours…" : exportLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{total === 0 ? (
|
||||
<EmptyState
|
||||
title={filtreActif ? "Aucune ligne ne correspond aux filtres" : emptyTitle}
|
||||
description={filtreActif ? "Modifiez la recherche ou effacez les filtres." : emptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]">
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const triable = Boolean(c.sortValue);
|
||||
const actif = tri?.id === c.id;
|
||||
const contenu = (
|
||||
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
|
||||
{c.header}
|
||||
{triable && (
|
||||
actif
|
||||
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
|
||||
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
scope="col"
|
||||
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)]",
|
||||
alignement(c.align),
|
||||
c.headerClassName,
|
||||
)}
|
||||
>
|
||||
{triable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => basculerTri(c.id)}
|
||||
className="hover:text-[var(--text-primary)]"
|
||||
title={typeof c.hint === "string" ? c.hint : undefined}
|
||||
>
|
||||
{contenu}
|
||||
</button>
|
||||
) : c.hint ? (
|
||||
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
|
||||
) : contenu}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignesAffichees.map((r, i) => (
|
||||
<tr
|
||||
key={rowKey(r, i)}
|
||||
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||
className={cn(
|
||||
"border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "center" && "text-center",
|
||||
c.className,
|
||||
)}
|
||||
>
|
||||
{c.cell(r)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{showFooter && (
|
||||
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "center" && "text-center",
|
||||
)}
|
||||
>
|
||||
{c.footer ? c.footer(triees) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagine && (
|
||||
<Pagination
|
||||
page={pageCourante}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={setPage}
|
||||
unite={unite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function DialogContent({
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">Fermer</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
@@ -111,7 +111,7 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">Fermer</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-2 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-primary)] shadow-[var(--shadow-md)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-1 text-[var(--text-primary)] shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1 text-[var(--text-primary)] shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Retours à l'utilisateur, communs à toute l'application :
|
||||
* - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite,
|
||||
* qui disparaît seul (remplace les fenêtres de succès et les `alert()`).
|
||||
* - `confirmer({...})` : demande de confirmation, renvoie une promesse de booléen
|
||||
* (remplace `window.confirm`, au rendu non maîtrisé et en anglais selon le poste).
|
||||
*
|
||||
* Le rendu est assuré par un unique `<FeedbackHost />` (cf. app/providers.tsx).
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TonToast = "succes" | "erreur" | "info";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
ton: TonToast;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DemandeConfirmation {
|
||||
titre: string;
|
||||
message?: string;
|
||||
libelleConfirmer: string;
|
||||
danger: boolean;
|
||||
resoudre: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeedbackState {
|
||||
toasts: ToastItem[];
|
||||
confirmation: DemandeConfirmation | null;
|
||||
}
|
||||
|
||||
const useFeedback = create<FeedbackState>(() => ({ toasts: [], confirmation: null }));
|
||||
|
||||
let prochainId = 1;
|
||||
|
||||
function ajouterToast(ton: TonToast, message: string, dureeMs: number) {
|
||||
const id = prochainId++;
|
||||
useFeedback.setState((s) => ({ toasts: [...s.toasts.slice(-3), { id, ton, message }] }));
|
||||
window.setTimeout(() => retirerToast(id), dureeMs);
|
||||
}
|
||||
|
||||
function retirerToast(id: number) {
|
||||
useFeedback.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
succes: (message: string) => ajouterToast("succes", message, 3500),
|
||||
erreur: (message: string) => ajouterToast("erreur", message, 7000),
|
||||
info: (message: string) => ajouterToast("info", message, 4500),
|
||||
};
|
||||
|
||||
interface OptionsConfirmation {
|
||||
titre: string;
|
||||
message?: string;
|
||||
/** Libellé du bouton de validation : un verbe (« Supprimer »), jamais « OK ». */
|
||||
libelleConfirmer?: string;
|
||||
/** Action irréversible : bouton rouge. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function confirmer(options: OptionsConfirmation): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// Une demande encore ouverte est considérée comme refusée.
|
||||
useFeedback.getState().confirmation?.resoudre(false);
|
||||
useFeedback.setState({
|
||||
confirmation: {
|
||||
titre: options.titre,
|
||||
message: options.message,
|
||||
libelleConfirmer: options.libelleConfirmer ?? "Confirmer",
|
||||
danger: options.danger ?? false,
|
||||
resoudre: resolve,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function repondre(ok: boolean) {
|
||||
const demande = useFeedback.getState().confirmation;
|
||||
if (!demande) return;
|
||||
useFeedback.setState({ confirmation: null });
|
||||
demande.resoudre(ok);
|
||||
}
|
||||
|
||||
const ICONES = { succes: CheckCircle2, erreur: XCircle, info: Info } as const;
|
||||
const COULEURS: Record<TonToast, string> = {
|
||||
succes: "var(--accent-success)",
|
||||
erreur: "var(--accent-error)",
|
||||
info: "var(--accent)",
|
||||
};
|
||||
|
||||
export function FeedbackHost() {
|
||||
const toasts = useFeedback((s) => s.toasts);
|
||||
const confirmation = useFeedback((s) => s.confirmation);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[300] flex w-[min(380px,calc(100vw-2rem))] flex-col gap-2"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const Icone = ICONES[t.ton];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role={t.ton === "erreur" ? "alert" : "status"}
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-[var(--shadow-lg)] animate-in fade-in-0 slide-in-from-bottom-2"
|
||||
style={{ background: "var(--bg-surface)", borderColor: "var(--border-strong)" }}
|
||||
>
|
||||
<Icone className="mt-0.5 h-5 w-5 shrink-0" style={{ color: COULEURS[t.ton] }} aria-hidden />
|
||||
<p className="flex-1 text-sm text-[var(--text-primary)]">{t.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retirerToast(t.id)}
|
||||
aria-label="Fermer le message"
|
||||
className="rounded p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmation !== null} onOpenChange={(open) => { if (!open) repondre(false); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmation?.titre}</DialogTitle>
|
||||
{confirmation?.message && (
|
||||
<DialogDescription className="text-[var(--text-secondary)]">{confirmation.message}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => repondre(false)}>Annuler</Button>
|
||||
<Button
|
||||
variant={confirmation?.danger ? "destructive" : "default"}
|
||||
onClick={() => repondre(true)}
|
||||
className={cn(confirmation?.danger && "dark:bg-destructive")}
|
||||
>
|
||||
{confirmation?.libelleConfirmer}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHAMP =
|
||||
"h-9 rounded-lg border px-3 text-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-primary)] " +
|
||||
"placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-bg)] " +
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed";
|
||||
|
||||
/** Libellé au-dessus d'un champ (et non à côté) : plus lisible en colonne étroite. */
|
||||
export function Label({ children, htmlFor, className }: { children: ReactNode; htmlFor?: string; className?: string }) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className={cn("block mb-1 text-[13px] font-medium text-[var(--text-secondary)]", className)}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
|
||||
label?: ReactNode;
|
||||
options: readonly SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
/** Option vide en tête (« Tous », « Tous les fournisseurs »…). */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Liste déroulante native, au style du thème. */
|
||||
export function Select({ label, options, onChange, placeholder, className, id, ...props }: SelectProps) {
|
||||
const select = (
|
||||
<select
|
||||
id={id}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)}
|
||||
{...props}
|
||||
>
|
||||
{placeholder !== undefined && <option value="">{placeholder}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (!label) return select;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{select}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "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 (
|
||||
<div className={cn("relative", className)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")}
|
||||
{...props}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn(CHAMP, className)} {...props} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<header className={cn("flex flex-wrap items-start justify-between gap-4 mb-6", className)}>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<div
|
||||
className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border"
|
||||
style={{ background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm max-w-3xl" style={{ color: "var(--text-secondary)" }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user