42 Commits
Author SHA1 Message Date
LogiFlow e0afbecffa Merge pull request #575 from R0m1k3/fix/weather-timestamptz
fix(rust) : lire les colonnes timestamp que la production déclare en …
2026-10-19 12:20:18 +02:00
MichaelandClaude Opus 5.5 4ed129bd74 fix(rust) : lire les colonnes timestamp que la production déclare en timestamptz
Au démarrage, la configuration météo était illisible
(« Rust type Option<NaiveDateTime> (as SQL type TIMESTAMP) is not compatible
with SQL type TIMESTAMPTZ ») et /api/weather/current renvoyait 500 :
weather_settings.created_at est timestamptz en production alors que
shared/schema.ts et init.sql le déclarent timestamp.

Les types réels de la production ne figurent dans aucune migration du dépôt :
plutôt que de deviner colonne par colonne, chaque colonne timestamp des
structures générées est lue par crate::db::AnyTimestamp(Opt), qui accepte
timestamp et timestamptz (rendu en UTC). Le champ reste NaiveDateTime et la
sérialisation (crate::json::ts, « …Z ») ne change pas.

scripts/gen-rust-schema.ts émet le même attribut #[sqlx(try_from)] ;
schema.rs est modifié à l'identique (62 colonnes).

Non compilé : pas de chaîne Rust sur ce poste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:58 +02:00
LogiFlow c869d7aa08 Merge pull request #574 from R0m1k3/fix/dlc-name-nullable
fix(dlc) : la liste DLC renvoyait 0 produit à cause d'anciennes ligne…
2026-10-19 11:21:43 +02:00
MichaelandClaude Opus 5.5 97d63b2097 fix(dlc) : la liste DLC renvoyait 0 produit à cause d'anciennes lignes sans nom
En production, 378 anciens produits DLC (ids 260 à 944, créés en 2025) ont
une colonne name à NULL. Le serveur Rust la décodait en String : sqlx
échouait sur ces lignes et toute réponse qui en contenait finissait en 500
(liste vide sans fournisseur choisi, 0 pour Baby délice, Depot, Lidis,
My Candy Shop ; SLS Agency et XMLS n'en ont aucune et s'affichaient).

name devient Option<String> dans DlcProductsRow, comme le générateur le
produit quand il tourne contre la base de production (colonne nullable).
La validation à la création reste inchangée : name y est toujours requis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:20:17 +02:00
LogiFlowandClaude Opus 5.5 af6a854400 fix(rust) : connexion impossible sur une base dont la table sessions est ancienne (#573)
L'ancien serveur gardait les sessions en mémoire : la table sessions de
production n'avait pas la forme attendue (sid, sess, expire) et la
connexion échouait (« column "sid" does not exist »). Au démarrage, une
telle table est renommée (sessions_ancienne_<date>, rien n'est supprimé),
son index aussi, puis la bonne table est créée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:46:16 +02:00
LogiFlowandClaude Opus 5.5 bffdca1297 fix(docker) : docker-compose.yml illisible depuis la suppression de Node (#572)
La suppression du service logiflow-node avait laissé un morceau de sa
définition entre le service logiflow et logiflow-db : le fichier n'était
plus du YAML valide et le déploiement échouait. La CI vérifie désormais
le fichier (docker compose config).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:34:46 +02:00
LogiFlowandClaude Opus 5.5 600627c632 feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:31:14 +02:00
LogiFlowandClaude Opus 5.5 6760f43e87 feat(api) : gérer les clés de l'API externe depuis Paramètres (#570)
Nouvel onglet Paramètres > API externe (admin) : état de l'API, adresse,
création d'une clé nommée par outil (affichée une seule fois), liste avec
dernière utilisation, révocation immédiate.

- table external_api_keys (empreinte SHA-256 uniquement), créée par
  migrations.production.ts et init.sql
- l'API accepte les clés de Paramètres et toujours celles de
  EXTERNAL_API_KEYS ; 503 seulement si aucune clé active
- routes /api/external-api/keys (session + admin)
- documentation et .env.example mis à jour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
2026-10-10 09:53:41 +02:00
LogiFlowandClaude Opus 5.5 b964e137bf feat(ui) : refonte du menu latéral et de la navigation téléphone (#569)
Menu PC et navigation téléphone tirés d'une configuration unique
(client/src/lib/navigation.ts), selon docs/PLAN-OPTIMISATION-WEBUI.md
(P1 § 2.1 et § 2.6).

- menu regroupé par usage (Au quotidien, Fournisseurs, Planning et infos,
  Administration), libellés clarifiés, chaque icône une seule fois ;
  droits d'accès PC inchangés
- l'envoi de BAP quitte le menu : bouton « Envoyer un BAP » (admin) dans
  l'en-tête du Rapprochement BL / factures
- téléphone : 4 raccourcis selon le rôle + « Menu » avec tous les modules
  autorisés, magasin, profil et déconnexion ; pages sans version mobile
  affichées dans le cadre téléphone
- titre de l'onglet du navigateur tiré du menu
- onglets du Rapprochement lisibles sur téléphone
- suppression de PhoneBottomNav.tsx (inutilisé)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
2026-10-10 09:35:35 +02:00
LogiFlowandClaude bd3bb5e64a feat(api): compléter la facture des fournisseurs en rapprochement automatique (#568)
Les livraisons d'un fournisseur en rapprochement automatique sont validées
d'office à la saisie du BL ; PATCH /api/ext/v1/deliveries/:id les refusait
donc (409) sauf à les dévalider. Pour ces fournisseurs, la référence, les
montants HT/TTC et l'échéance s'écrivent désormais directement, la livraison
restant validée.

- automaticReconciliation exposé sur chaque livraison renvoyée par l'API
- documentation mise à jour avec un exemple


Claude-Session: https://claude.ai/code/session_01LEhR8Cok79VpsxixhrKChM

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 09:11:08 +02:00
MichaelandClaude Opus 5.5 6f1a39e4b2 feat(api): API externe de rapprochement BL / factures
Nouvelle API sous /api/ext/v1, authentifiée par clé (EXTERNAL_API_KEYS,
en-tête X-API-Key ou Authorization: Bearer), pour qu'un outil tiers
lise magasins, fournisseurs et livraisons livrées (n° BL) puis écrive
la référence, les montants et l'échéance de la facture, avec
validation/dévalidation optionnelle du rapprochement.

- normalizeDateString extrait dans server/dateUtils.ts pour être partagé
- /api/ext/ exempté du CSRF (pas de cookie de session)
- EXTERNAL_API_KEYS ajouté à .env.example et docker-compose.yml
- documentation : docs/API-RAPPROCHEMENT.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:14:45 +02:00
MichaelandClaude Opus 5.5 2274c9bb4a fix(reconciliation): auto-reconcile deliveries from automatic suppliers on validation
POST /api/deliveries/:id/validate never set reconciled=true for suppliers in
automatic reconciliation mode, so their deliveries showed in neither the
Manuels tab (auto suppliers excluded) nor Validées (reconciled=false).

- validate endpoint now marks reconciled + validatedAt for auto suppliers
- Validées tab includes all deliveries from auto suppliers
- migration backfills reconciled for existing delivered deliveries with BL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:43 +02:00
Claude 944d99733c fix(meteo): afficher la réponse de l'API même si son enregistrement échoue
La température de l'an dernier disparaissait alors que Visual Crossing
répondait correctement (tableau de bord : requêtes réussies, 0 échec,
et l'URL d'historique testée dans le navigateur renvoie bien la journée).
Quand l'enregistrement de la réponse en cache échouait (par exemple une
contrainte d'unicité sur la date déjà occupée par la météo relevée ce
jour-là l'an dernier), la route relisait le cache, n'y trouvait rien et
jetait la donnée reçue : previousYear restait à null et l'API était
réinterrogée à chaque affichage.

La route garde désormais la réponse de l'API pour l'affichage quand
l'enregistrement échoue (année en cours et année précédente), et
journalise une réponse d'historique vide. Reproduit sur un Postgres local
avec une contrainte UNIQUE (date, location) et une API simulée renvoyant
la réponse réelle : N-1 vide avant, 14,1° après ; sans contrainte, le
comportement est inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-05 14:22:44 +00:00
Claude 8a27b310d2 fix(meteo): repli sur la météo relevée l'an dernier pour la comparaison N-1
La comparaison avec l'année dernière disparaît quand Visual Crossing
refuse durablement l'historique (plan, quota) : le repli précédent ne
cherchait que des lignes N-1 déjà en cache (is_current_year = false) à
7 jours près, qui n'existent plus si l'historique échoue depuis plus
d'une semaine.

La table weather_data garde pourtant la météo relevée chaque jour l'an
dernier, quand ces dates étaient « aujourd'hui » (is_current_year =
true). La route l'utilise désormais en second repli, au jour le plus
proche de la date cible (7 jours max), et journalise l'absence totale de
donnée N-1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-05 14:10:06 +00:00
Claude 6bb61cdbcc fix(db): ne plus arrêter le serveur quand Postgres coupe une connexion
Quand la base redémarre (redéploiement, restart du conteneur) ou que le
réseau coupe, pg-pool émet 'error' pour chaque connexion inactive
interrompue. Aucun écouteur n'était branché sur le pool de server/db.ts :
Node levait l'événement non géré et arrêtait tout le serveur, avec un
dump géant du client pg dans les logs. Le pool écarte déjà la connexion
fautive et en ouvre une neuve à la requête suivante : on se contente de
journaliser le code et le message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-05 14:07:23 +00:00
Claude d384271a30 fix(meteo): rétablir les appels séquentiels à l'API météo
La comparaison avec l'année dernière avait disparu du widget météo depuis
l'optimisation des chargements. La route /api/weather/current interrogeait
Visual Crossing pour aujourd'hui et pour l'année dernière en parallèle :
la seconde requête d'une même clé peut alors être refusée (limite de
requêtes simultanées), laissant previousYear à null. Les deux appels
redeviennent séquentiels comme avant ; seules les lectures du cache local
restent en parallèle.

Le widget retrouve son comportement d'origine (plus de cache de 30
minutes ni de retry désactivé), pour qu'un échec ponctuel ne masque plus
la comparaison pendant une demi-heure. Les erreurs de l'API journalisent
désormais la raison renvoyée par Visual Crossing (quota, plan, clé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-04 18:13:03 +00:00
Claude f4c582b217 docs: plan d'optimisation du webUI (lisibilité, simplicité, performance)
Résumé, feuille de route en phases avec critères mesurables, décisions
produit à prendre, principes et design system, plan page par page, bugs
et dette, gains mesurés du lot de performance et lots restants. Chaque
action renvoie aux constats de docs/audit-webui/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-03 04:29:51 +00:00
Claude 5d3e5357a0 perf(client): pages chargées à la demande et un seul appel /api/user
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans
  la zone de contenu ; recharts n'est plus préchargé. JS initial :
  1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un
  ancien fichier JS a disparu après un déploiement, et ErrorBoundary par
  page pour garder le menu affiché en cas d'erreur.
- Authentification unifiée sur le cache React Query ['/api/user'] : un
  seul GET /api/user pour toute l'application (au lieu de 4 à 5 par
  page), plus de rechargement complet après la connexion.
- Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en
  cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement
  qui ne re-rend que lorsque le palier d'écran change.
- Pages : requêtes inutilisées ou en double supprimées, appels lancés
  seulement une fois l'utilisateur connu, invalidations ciblées au lieu
  de rechargements complets, filtres et tris mémoïsés, anciennes données
  gardées (et estompées) pendant un changement de filtre ou de mois,
  recherche DLC mobile temporisée, vérifications de factures à
  concurrence bornée, navigation interne sans rechargement de la page.
- index.html : script de bannière Replit retiré, lang="fr".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-03 04:29:51 +00:00
Claude a5785f0244 perf(serveur): réponses API allégées, compression, cache des assets et index
- Fichiers statiques servis avant la session (plus de requêtes SQL par
  asset), /assets en cache immuable 1 an, index.html en no-cache, et
  compression gzip/brotli des réponses (dépendance compression, externe
  dans le bundle esbuild du Dockerfile).
- req.user (déjà chargé par deserializeUser) réutilisé dans les handlers
  au lieu de relire l'utilisateur et ses magasins à chaque appel ;
  GET /api/user ne refait plus de requête.
- Listes : le magasin joint est réduit aux champs lus par l'interface
  (plus de logo base64 ni de configuration SMTP/NocoDB dans chaque ligne),
  plus aucune empreinte de mot de passe dans les créateurs/auteurs ni dans
  /api/users.
- N+1 supprimés (/api/users, annonces, historique SAV, caches de
  vérification des factures), requêtes indépendantes en parallèle (stats,
  analytics, météo, getDelivery, getUserWithGroups), jointure
  multiplicative des statistiques par magasin corrigée.
- Échéancier limité au magasin demandé ; filtre status sur
  GET /api/deliveries.
- Index de performance créés en arrière-plan au démarrage
  (CREATE INDEX CONCURRENTLY, reliquats invalides purgés sans verrou
  exclusif).
- La connexion n'attend plus la sauvegarde quotidienne ; purge du cache
  des factures active en production ; logs volumineux retirés des
  chemins chauds ; NODE_ENV fixé dans l'image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-03 04:29:51 +00:00
Claude 3f2660c506 docs(audit): analyse complète du webUI page par page (620 constats vérifiés)
Annexe générée par un audit multi-agents : pour chaque page, rôle,
tâches utilisateur, appels API tracés jusqu'au handler serveur, et
constats UX / lisibilité / performance / bugs avec preuve fichier:ligne,
vérifiés de façon contradictoire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-03 00:27:29 +00:00
Claude 3eea2eb47b fix(meteo): fall back to nearest cached data when the history API fails
The dashboard widget lost the previous-year temperature and the difference:
when the day's previous-year row is not yet cached and the Visual Crossing
history call fails (quota exhausted, outage, or a plan without history
access), the route silently returned previousYear: null and the widget
hid the comparison.

The weather cache accumulates one previous-year row per day, so the route
now falls back to the nearest cached previous-year entry within 7 days of
the weekday-aligned target date, keeping the comparison on screen through
API failures. The exact API error remains logged server-side
(🌤️ [ERROR] / [FETCH-HISTORY]) for diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-15 21:08:44 +00:00
Claude c375229c05 Merge remote-tracking branch 'origin/main' 2026-08-15 21:00:01 +00:00
Claude bc401963c1 Merge branch 'claude/focused-volta-u3pwmj' 2026-08-15 20:59:53 +00:00
LogiFlow 6cf779092e Merge pull request #564 from R0m1k3/claude/focused-volta-u3pwmj
Claude/focused volta u3pwmj
2026-08-15 22:59:47 +02:00
Claude d09821ddc0 fix(magasins): two-column store form so the modal fits the screen
The stacked form outgrew the viewport even with internal scrolling. The
dialog widens to max-w-4xl and the form becomes a two-column grid on
desktop: identity and NocoDB configuration on the left, store contact
details and SMTP settings on the right — the SMTP section is now visible
without scrolling. Single column is preserved on small screens, and the
90vh cap with internal scroll stays as a safety net.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-15 20:57:32 +00:00
Claude a7e44f0601 fix(magasins): make the store form modal scrollable
The store contact and SMTP sections made the form taller than the viewport
and the dialog had no scroll, cutting off the bottom fields and buttons.
The dialog now caps at 85vh with internal scrolling, slightly widened so
the two-column SMTP fields breathe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 17:07:17 +00:00
LogiFlow 1b9e3ac493 Merge pull request #563 from R0m1k3/claude/focused-volta-u3pwmj
Claude/focused volta u3pwmj
2026-08-14 19:01:09 +02:00
Claude 4220131432 fix(types): bring real code to zero TypeScript errors, fix three latent bugs
Continues the cleanup from 164 errors down to 88, all of which now sit in
MemStorage — the in-memory dev mock — documented as known debt. Production
server code and the entire client are at zero errors.

Three genuine defects surfaced by the types and fixed:
- deleteAnnouncement returned void while routes check the result and answer
  404 "not found" on falsy: deleting an announcement succeeded in DB but the
  API reported failure on the fallback paths; it now returns a real boolean
- AnnouncementMemoryStorage declared getAnnouncement twice; the first
  implementation was dead at runtime (second definition wins) and is removed
- one route called storage.getDeliveryById(), a method that does not exist,
  crashing with a TypeError whenever hit; it now calls getDelivery()

Everything else is type-level only (annotations, null-to-undefined for
inline styles, honest signatures for markClientCalled's comment parameter
and the invoice verification result's invoiceAmountTTC field), verified
behavior-neutral: client build, production server bundle and the SMTP
end-to-end test all pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:42:24 +00:00
Claude 0a816fa881 fix(securite): lock down the two unauthenticated emergency endpoints
- POST /api/emergency-admin-reset accepted a fallback secret hardcoded in
  the repository, letting anyone who read the source reset the production
  admin account to admin/admin (EMERGENCY_SECRET is not set in the shipped
  docker-compose, so the fallback was live). The route now returns 404
  unless EMERGENCY_SECRET is explicitly configured, and invalid attempts
  are logged.
- POST /api/admin/emergency-migration ran database migrations with no
  authentication at all; it now requires an authenticated admin.

A sweep of the remaining API surface found no other unauthenticated
mutation or read routes beyond /api/health. .env.example documents
EMERGENCY_SECRET and ENCRYPTION_KEY.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:21:40 +00:00
Claude 37a8d2b40c fix(logs): redact secrets from request-body logs, cut render-time debug noise
- POST /api/groups logged the full request body, which now carries the
  store's SMTP password; a redactBody() helper masks smtpPassword, apiToken
  and password in the six log sites that print request bodies
- Groups.tsx logged a debug object on every render; removed
- BLReconciliation's auto-fill mutation logged five lines per verified
  delivery; collapsed to one DEV-gated line, keeping console.error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:20:29 +00:00
Claude 9dc6d9567c perf(build): split vendor chunks and strip debug logs from production bundle
- manualChunks separates react/wouter, react-query, recharts and lucide
  from application code: the main bundle drops from 1.73 MB to 1.08 MB and
  vendor chunks stay browser-cached across deployments since they only
  change on dependency upgrades
- esbuild "pure" removes console.log/console.debug from production builds
  (console.warn/error kept); the Dockerfile now builds the frontend with
  NODE_ENV=production so the setting actually applies — this silences the
  debug-log flood users saw in the browser console in production

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:20:29 +00:00
Claude f5bc114a82 fix(types): drop the 2017 @types/date-fns stub shadowing date-fns 3 types
The tsconfig "types" array forced @types/date-fns@2.5.3 — a deprecated stub
for date-fns v2.5 — over the real types shipped by date-fns 3.6, producing
phantom errors everywhere dates are formatted (Locale unknown, format()
refusing its options argument, parseISO missing). Removing the stub and the
types entry (plus the unused node-fetch entry) clears them at the root.

Also applies the non-breaking npm audit fixes: 16 production vulnerabilities
down to 2 (drizzle-orm and xlsx, both needing review — see commit history).

TypeScript errors: 261 → 177.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:20:29 +00:00
Claude af3800aa09 chore: remove committed session cookies, debug artifacts and dead code
Repo hygiene pass:
- cookie.txt / cookies.txt held real session cookies and must never be
  committed; .gitignore now blocks them along with .env files
- half a megabyte of debug screenshots, one-shot production hotfix scripts
  (all superseded by the automatic startup migrations), scratch files and
  the unused attached_assets folder (with its dangling @assets vite alias)
- dead code: server/storage-old.ts (unreferenced, 167 of the project's 430
  TypeScript errors) and five client pages no route ever imported
  (BLReconciliationNative, Avoirs_backup, TasksSimplified, TasksListSimple,
  TasksProductionSimple)

TypeScript error count drops from 430 to 261 with no behavior change; the
client build is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:14:45 +00:00
Claude 9de717387f feat(securite): encrypt SMTP passwords and NocoDB tokens at rest, log supplier mails
Encryption (server/crypto.ts):
- AES-256-GCM with a key derived from ENCRYPTION_KEY, falling back to
  SESSION_SECRET so existing deployments need no new configuration
- stored format enc:v1:<iv>:<tag>:<data>; encryption is idempotent and
  decryption passes legacy plaintext through unchanged, so nothing breaks
  mid-migration
- tampered data or a changed key raises an explicit error instead of
  returning garbage
- encrypt/decrypt is confined to the storage layer: group writes encrypt
  smtpPassword (decrypted only in emailService at connection time, never sent
  to the client), NocoDB config writes encrypt apiToken and reads decrypt it
  so the invoice verification and the admin page behave as before
- startup migration sweep encrypts secrets already stored in plaintext,
  idempotently; the active-config log line no longer prints the token

Mail history (supplier_mail_logs):
- every send attempt is recorded: delivery, store, supplier, recipient,
  subject, status sent/failed with error, message id, user id and name;
  a logging failure never fails the send itself
- GET /api/supplier-mail-logs restricted to the user's stores (admin may
  filter by store)
- on the reconciliation page the mail icon turns green once a request has
  been sent, with the date and sender in the tooltip; clicking again resends
- table created in init.sql, versioned migrations and the production
  startup migration, with delivery/group indexes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 16:02:55 +00:00
Claude 012024a293 feat(mails): send supplier document requests over each store's own SMTP
Replaces the mailto: link with a server-side send, so the message carries the
store's signature and logo instead of depending on each workstation's Outlook.

Store record (groups):
- address, phone and logo (data URI, 200 KB cap) feed the mail signature
- per-store SMTP settings: host, port, SSL/STARTTLS, credentials, sender
  address and display name, with an enable switch
- "test connection" button verifies the server without sending anything
- the empty-form literal, previously repeated five times, becomes one constant

Sending:
- nodemailer transport built per store from its own settings
- multipart mail: plain-text alternative plus HTML whose signature embeds the
  logo as an inline CID attachment, which Outlook renders without the remote
  image blocking that a data: URI would hit
- delivery details are HTML-escaped
- Reply-To set to the store address; the row shows a spinner and refuses a
  second click while a send is in flight

Credentials:
- the SMTP password is never returned to the client; a response-layer
  sanitizer strips it from every /api payload and replaces it with a
  smtpPasswordSet flag, covering the ten-plus queries that join full group
  rows into deliveries, orders and user relations
- an empty password field on save keeps the stored one rather than clearing it

Verified end-to-end against a local SMTP server: transport, auth, From/Reply-To,
multipart structure and the inline logo attachment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 15:25:00 +00:00
LogiFlow baaa27af50 Merge pull request #562 from R0m1k3/claude/focused-volta-u3pwmj
Claude/focused volta u3pwmj
2026-08-14 16:35:55 +02:00
Claude 02645c34a5 refactor(rapprochement): end supplier mail body at the closing line
The generated body stopped with the brand name and the store, which would be
repeated by the Outlook signature configured on each workstation. It now ends
at "Cordialement," and lets that signature carry the brand, the store details
and the logo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 14:29:06 +00:00
Claude 02b70d6b59 fix(rapprochement): stop the auto-verification request flood
The reconciliation page saturated the browser with concurrent requests
(net::ERR_INSUFFICIENT_RESOURCES), re-verifying the same deliveries dozens
of times. Three compounding causes:

- the auto-verification effect depended on verificationResults and
  verifyingDeliveries, so every incoming result re-ran it and re-scheduled
  the same deliveries; the effect now depends only on the data, and
  de-duplication uses a ref applied synchronously instead of state
- verifications fired all at once; they now go through a queue capped at 3
  concurrent requests, which also drops the random 0-1s scatter and the
  200ms stagger of "verify all"
- each auto-filled delivery invalidated the deliveries cache, triggering a
  refetch that re-ran the effect; invalidation now happens once, when the
  queue drains

Also fixes cached results computed after the setState that was supposed to
apply them, so deliveries with a known invoice amount never got their green
check and were re-examined on every pass, and silences toasts for automatic
verifications (one toast per row on a network outage).

Mail signature is now the LaFoir'Fouille brand plus the store recorded on
the delivery, instead of the current user's name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 12:04:35 +00:00
Claude ee4fca64d0 feat(rapprochement): add supplier mail button to request invoice PDF or BL Excel
Each reconciliation row now shows a mail icon that opens the default mail
client (Outlook) with the supplier address, subject and body pre-filled,
asking for the invoice as PDF or the delivery note as Excel.

- new client/src/lib/supplierMail.ts helper building the subject, body and
  mailto URL from the delivery (supplier, store, delivery date, BL number,
  BL amount, invoice reference) with the current user as signature
- body line breaks encoded as CRLF per RFC 6068 so Outlook keeps the layout
- supplier email read from the delivery join, with fallback to the suppliers
  list; when no email is set the button explains where to add it
- button added to both the manual and validated tabs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-14 11:47:49 +00:00
MichaelandClaude Fable 5 753b301066 perf: add missing indexes and remove N+1 queries
The app degraded progressively over a year of data growth. Four causes,
all cumulative:

1. No indexes. Apart from primary keys, unique constraints and
   session.expire, no table carried an index. PostgreSQL does not index
   foreign keys automatically, so every filter and join on group_id,
   supplier_id, order_id and the date columns did a sequential scan.
   Worst offender: user_groups.user_id, read by getUserWithGroups() on
   every authenticated request.

2. N+1 in the order and delivery listings. getOrders,
   getOrdersByDateRange, getDeliveries and getDeliveriesByDateRange
   issued one to two queries per row to load relations. Relations are now
   loaded in bulk and grouped in Node: three queries regardless of volume.

3. /api/sync-order-delivery-status reloaded the whole deliveries table on
   every iteration of its loop over orders. It now uses the deliveries
   getOrders() already attaches.

4. clearExpiredCache() was implemented but never called, so
   invoice_verification_cache grew without bound. Now scheduled every 6h.

Also replaces the full-history downloads on the Groups and Suppliers
pages, which fetched every order and delivery with nested relations only
to count rows, with aggregate endpoints that count in the database.

Indexes are created via scripts/auto-migrate-production.sh, the script
that actually runs at deploy time, using CREATE INDEX CONCURRENTLY so no
write lock is taken. Note that server/migrations.ts explicitly ignores
the migrations/ directory and runs only hardcoded migrations; the SQL
file added there is for reference and manual application.

Verified: typecheck baseline 440 errors, 430 after, none new in the
changed code; vite build passes; server boots; functional test confirms
the aggregate endpoints match the source data including the delivered
count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:55:49 +02:00
MichaelandClaude Sonnet 4.6 693d9460cb feat(contacts): add company field to free contacts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:29:56 +02:00
MichaelandClaude Sonnet 4.6 a430aa1910 feat(contacts): allow admin/directeur to edit supplier coordinates from contacts page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:27:33 +02:00
268 changed files with 46955 additions and 28936 deletions

No files matched your search

+7
View File
@@ -94,3 +94,10 @@ ssl/
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# Compilation Rust locale (le binaire est construit dans l'image)
server-rs/target/
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
.bench-reference/
# Interface construite en local : reconstruite dans l'image
dist/
+14
View File
@@ -22,3 +22,17 @@ POSTGRES_PORT=5434
# Note de sécurité: # Note de sécurité:
# Les identifiants sont préconfigurés pour simplifier le déploiement. # Les identifiants sont préconfigurés pour simplifier le déploiement.
# En production, vous pouvez les modifier si nécessaire. # En production, vous pouvez les modifier si nécessaire.
# Chiffrement des secrets en base (mots de passe SMTP, jetons NocoDB).
# Repli sur SESSION_SECRET si absent. ATTENTION : changer cette clé rend
# les secrets déjà chiffrés illisibles.
ENCRYPTION_KEY=
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
# Ne définir qu'en cas de besoin, puis retirer.
EMERGENCY_SECRET=
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
# Les clés se créent de préférence dans Paramètres > API externe. Optionnel :
# une ou plusieurs clés supplémentaires ici, séparées par des virgules.
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
EXTERNAL_API_KEYS=
+10
View File
@@ -0,0 +1,10 @@
# Décision R6 (docs/PLAN-MIGRATION-RUST.md) : le serveur Rust, le banc qui le
# vérifie et les fichiers dont il dépend sont relus par leur mainteneur.
/server-rs/ @R0m1k3
/scripts/bench/ @R0m1k3
/scripts/gen-rust-schema.ts @R0m1k3
/shared/schema.ts @R0m1k3
/shared/permissions.json @R0m1k3
/Dockerfile @R0m1k3
/docker-compose.yml @R0m1k3
/.github/workflows/ @R0m1k3
+90
View File
@@ -0,0 +1,90 @@
name: Serveur Rust
# Contrôles du serveur Rust, contrôle de type et construction de l'interface, et
# banc de comparaison avec le serveur Node de référence (scripts/bench/README.md)
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
checks:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Mise en forme
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets --locked -- -D warnings
- name: Tests
run: cargo test --locked
client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Dépendances
run: npm ci
- name: Contrôle de type
run: npm run check
- name: Construction de l'interface
run: npx vite build
- name: Fichier docker-compose valide
run: docker compose -f docker-compose.yml config -q
bench:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: logiflow_admin
POSTGRES_PASSWORD: bench
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U logiflow_admin"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
BENCH_PG_URL: postgres://logiflow_admin:bench@localhost:5432
steps:
- uses: actions/checkout@v4
with:
# Le serveur Node de référence est construit depuis un commit passé
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Dépendances
run: npm ci
- name: Serveur Node de référence (REFERENCE_NODE_COMMIT)
run: scripts/bench/reference-node.sh
- name: Serveur Rust
run: cargo build --release --locked --manifest-path server-rs/Cargo.toml
- name: Base modèle du banc
run: scripts/bench/setup.sh
- name: Banc Node de référence contre Rust
run: npx tsx scripts/bench/compare.ts
+12 -1
View File
@@ -1,6 +1,17 @@
node_modules node_modules
dist dist
.DS_Store .DS_Store
server/public
vite.config.ts.* vite.config.ts.*
*.tar.gz *.tar.gz
# Secrets et fichiers de session : ne jamais committer
.env
.env.local
cookie*.txt
# Artefacts de debug locaux
*.log
scratch/
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
.bench-reference/
+34 -103
View File
@@ -1,118 +1,49 @@
# Multi-stage build for optimized production image # Image de production : interface construite par Vite + serveur Rust (server-rs/).
FROM node:20-alpine AS build # Node ne sert qu'à construire l'interface ; il n'est pas dans l'image finale.
# Set working directory # Interface (dist/public)
FROM node:20-alpine AS client-build
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
# Install all dependencies (including dev dependencies needed for build)
RUN npm ci && npm cache clean --force RUN npm ci && npm cache clean --force
COPY tsconfig.json vite.config.ts tailwind.config.ts postcss.config.js components.json ./
# Copy source code (excluding .env to avoid conflicts)
COPY package*.json ./
COPY tsconfig.json ./
COPY drizzle.config.ts ./
COPY vite.config.ts ./
COPY tailwind.config.ts ./
COPY postcss.config.js ./
COPY components.json ./
COPY client/ client/ COPY client/ client/
COPY server/ server/
COPY shared/ shared/ COPY shared/ shared/
RUN NODE_ENV=production npx vite build && test -f dist/public/index.html
# Vérifier la structure # Serveur : binaire statique (musl) pour l'image Alpine
RUN ls -la server/ FROM rust:1.97-alpine AS rust-build
RUN apk add --no-cache musl-dev
WORKDIR /build
# Dépendances d'abord : couche en cache tant que Cargo.lock ne change pas
COPY server-rs/Cargo.toml server-rs/Cargo.lock ./server-rs/
RUN mkdir -p server-rs/src && echo "fn main() {}" > server-rs/src/main.rs && \
cargo build --release --locked --manifest-path server-rs/Cargo.toml && \
rm -rf server-rs/src
COPY server-rs/build.rs ./server-rs/build.rs
COPY server-rs/src ./server-rs/src
COPY shared/permissions.json ./shared/permissions.json
RUN touch server-rs/src/main.rs && \
cargo build --release --locked --manifest-path server-rs/Cargo.toml
# Build the application FROM alpine:3.20 AS production
# Build frontend first # pg_dump/psql pour les sauvegardes, wget pour la sonde de santé
RUN npx vite build RUN apk add --no-cache postgresql-client wget tzdata ca-certificates && \
addgroup -g 1001 -S logiflow && adduser -S logiflow -u 1001 -G logiflow
# Vérifier que les fichiers sont construits
RUN echo "=== BUILD VERIFICATION ===" && \
ls -la dist/ && \
echo "Frontend files in dist/public:" && \
ls -la dist/public/ && \
echo "index.html exists:" && \
ls -la dist/public/index.html
# Build backend avec tous les modules externes
RUN npx esbuild server/index.production.ts \
--platform=node \
--bundle \
--format=esm \
--outfile=dist/index.js \
--external:vite \
--external:@vitejs/* \
--external:@replit/* \
--external:tsx \
--external:openid-client \
--external:@neondatabase/serverless \
--external:ws \
--external:drizzle-orm \
--external:pg \
--external:express \
--external:connect-pg-simple \
--external:passport \
--external:passport-local \
--external:express-session \
--external:zod \
--external:express-rate-limit \
--external:memoizee \
--external:nanoid \
--external:date-fns
# Production stage
FROM node:20-alpine AS production
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Set working directory
WORKDIR /app WORKDIR /app
COPY --from=rust-build /build/server-rs/target/release/logiflow /app/bin/logiflow
COPY --from=client-build /app/dist/public /app/dist/public
COPY migrations/ /app/migrations/
RUN mkdir -p /app/backups && chown -R logiflow:logiflow /app/backups
USER logiflow
# Install production dependencies only ENV PORT=3000 \
COPY --from=build /app/package*.json ./ STATIC_DIR=/app/dist/public \
RUN npm ci --only=production && npm cache clean --force BACKUP_DIR=/app/backups \
NODE_ENV=production
# Copy built application from build stage
COPY --from=build --chown=nextjs:nodejs /app/dist ./dist
COPY --from=build --chown=nextjs:nodejs /app/shared ./shared
# Copy migration scripts and files
COPY --chown=nextjs:nodejs scripts/ ./scripts/
COPY --chown=nextjs:nodejs migrations/ ./migrations/
COPY --chown=nextjs:nodejs scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh
# Create symlink for public files to be accessible from dist
RUN ln -sf /app/dist/public /app/dist/public
# Create uploads and backups directories with proper permissions
RUN mkdir -p /app/uploads /app/backups && chown -R nextjs:nodejs /app/uploads /app/backups
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000
# Install wget for health check and postgresql-client for pg_dump
USER root
RUN apk add --no-cache wget postgresql-client
USER nextjs
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Make scripts executable CMD ["/app/bin/logiflow"]
RUN chmod +x /app/scripts/auto-migrate-production.sh && \
chmod +x /app/scripts/docker-entrypoint.sh && \
ls -la /app/scripts/ && \
test -f /app/scripts/docker-entrypoint.sh || echo "ERREUR: docker-entrypoint.sh manquant"
# Apply DLC migration directly and start application
CMD ["sh", "-c", "echo 'Applying DLC migration...'; if [ -n \"$DATABASE_URL\" ]; then psql \"$DATABASE_URL\" -c 'ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp; CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);' || echo 'Migration already applied or failed'; fi; node dist/index.js"]
-24
View File
@@ -1,24 +0,0 @@
-- MIGRATION URGENTE PRODUCTION DLC
-- Exécutez cette commande directement sur votre serveur
-- Ajout des colonnes DLC stock épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Index pour performance
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
-- Initialisation des valeurs
UPDATE dlc_products
SET stock_epuise = false
WHERE stock_epuise IS NULL;
-- Vérification
SELECT
'Migration DLC terminée' as status,
COUNT(*) as colonnes_ajoutees
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at');
+28 -17
View File
@@ -44,8 +44,8 @@ Une plateforme d'entreprise moderne pour la gestion logistique, le suivi des liv
## 🚀 Installation et Configuration ## 🚀 Installation et Configuration
### Prérequis ### Prérequis
- Node.js 18+ - Rust stable (serveur) et Node.js 20+ (construction de l'interface uniquement)
- PostgreSQL 13+ - PostgreSQL 13+ et `pg_dump` (sauvegardes)
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/)) - Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
### Installation ### Installation
@@ -61,13 +61,18 @@ npm install
cp .env.example .env cp .env.example .env
# Éditer .env avec vos paramètres de base de données # Éditer .env avec vos paramètres de base de données
# Initialiser la base de données # Démarrer le serveur (API, migrations au démarrage) sur le port 3000
npm run db:push npm run dev:server
# Démarrer l'application # Dans un autre terminal : l'interface avec rechargement à chaud (Vite),
# qui relaie /api vers le serveur
npm run dev npm run dev
``` ```
En production : `npm run build` (interface dans `dist/public`, binaire dans
`server-rs/target/release/logiflow`) puis `npm start`, ou l'image Docker
(`docker compose up -d`).
### Configuration de l'API Météo ### Configuration de l'API Météo
1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/) 1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/)
2. Obtenez votre clé API 2. Obtenez votre clé API
@@ -122,11 +127,9 @@ npm run dev
- **Wouter** : Routage léger et performant - **Wouter** : Routage léger et performant
### Backend ### Backend
- **Node.js** : Runtime JavaScript haute performance - **Rust** (axum, tokio, sqlx) : serveur unique, binaire statique (voir `server-rs/README.md`)
- **Express** : Framework web minimaliste - **Schéma** : `shared/schema.ts` (Drizzle) reste la source, d'où est généré `server-rs/src/schema.rs`
- **TypeScript** : Développement sécurisé côté serveur - **Zod** : validation côté interface ; mêmes règles et mêmes messages côté serveur
- **Drizzle ORM** : ORM moderne et type-safe
- **Zod** : Validation de schémas robuste
### Base de Données ### Base de Données
- **PostgreSQL** : Base de données relationnelle performante - **PostgreSQL** : Base de données relationnelle performante
@@ -146,13 +149,16 @@ npm run dev
│ │ ├── pages/ # Pages de l'application │ │ ├── pages/ # Pages de l'application
│ │ ├── lib/ # Utilitaires et configuration │ │ ├── lib/ # Utilitaires et configuration
│ │ └── hooks/ # Hooks React personnalisés │ │ └── hooks/ # Hooks React personnalisés
├── server/ # Backend Express ├── server-rs/ # Serveur Rust (API, interface, migrations, tâches de fond)
│ ├── routes.ts # Définition des routes API │ └── src/
│ ├── storage.ts # Interface de stockage │ ├── router.rs # Table des adresses de l'API
│ ├── weatherService.ts # Service météorologique │ ├── modules/ # Une famille de routes par fichier
│ └── index.ts # Point d'entrée du serveur │ ├── startup.rs # Migrations au démarrage
│ └── jobs.rs # Sauvegardes automatiques, purge des caches
├── shared/ # Code partagé ├── shared/ # Code partagé
│ └── schema.ts # Schémas de base de données │ ├── schema.ts # Schémas de base de données (source de schema.rs)
│ └── permissions.json # Matrice des droits (interface et serveur)
├── scripts/bench/ # Banc de comparaison avec l'ancien serveur Node
├── migrations/ # Migrations de base de données ├── migrations/ # Migrations de base de données
└── backups/ # Dossier des sauvegardes └── backups/ # Dossier des sauvegardes
``` ```
@@ -168,8 +174,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
VISUAL_CROSSING_API_KEY=votre_clé_api_ici VISUAL_CROSSING_API_KEY=votre_clé_api_ici
# Application # Application
NODE_ENV=production
PORT=3000 PORT=3000
SESSION_SECRET=… # signe le cookie de session
ENCRYPTION_KEY=… # chiffre les secrets en base (repli : SESSION_SECRET)
STATIC_DIR=dist/public # interface construite par Vite
BACKUP_DIR=/app/backups
ALLOWED_ORIGINS= # autres origines acceptées pour les écritures (facultatif)
RUST_LOG=info
``` ```
### Personnalisation ### Personnalisation
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

@@ -1,245 +0,0 @@
Images
Networks
Volumes
Events
Host
Administration
User-related
Environment-related
Registries
Logs
Notifications
Settings
New version available 2.33.3
DismissSee what's new
Community Edition
2.27.6 LTS
Containers>logiflow-logiflow-1>Logs
Container logs
admin
Log viewer settings
Auto-refresh logs
Wrap lines
Display timestamps
Fetch
All logs
Search
Filter...
Lines
100
Actions
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 18ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
GET /api/groups 304 in 4ms
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
❌ [PRODUCTION] User not found with username: admin_local
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
GET /api/user 304 in 13ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 58ms
@@ -1,245 +0,0 @@
Images
Networks
Volumes
Events
Host
Administration
User-related
Environment-related
Registries
Logs
Notifications
Settings
New version available 2.33.3
DismissSee what's new
Community Edition
2.27.6 LTS
Containers>logiflow-logiflow-1>Logs
Container logs
admin
Log viewer settings
Auto-refresh logs
Wrap lines
Display timestamps
Fetch
All logs
Search
Filter...
Lines
100
Actions
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 18ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
GET /api/groups 304 in 4ms
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
❌ [PRODUCTION] User not found with username: admin_local
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
GET /api/user 304 in 13ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 58ms
@@ -1,202 +0,0 @@
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 9ms
GET /api/groups 304 in 3ms
GET /api/user 304 in 5ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 45ms
HEAD /api/health 200 in 0ms
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

+1 -3
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
@@ -15,7 +15,5 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body> </body>
</html> </html>
+42 -46
View File
@@ -49,7 +49,7 @@ import {
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
// Type pour les annonces avec relations // Type pour les annonces avec relations
type AnnouncementWithRelations = DashboardMessage & { export type AnnouncementWithRelations = DashboardMessage & {
author: { author: {
id: string | number; id: string | number;
firstName?: string; firstName?: string;
@@ -97,6 +97,45 @@ const getPriorityConfig = (type: string) => {
} }
}; };
// Requête des annonces, partagée avec le tableau de bord (même clé, même URL) :
// un seul appel réseau, et les invalidations ['/api/announcements'] des
// mutations ci-dessous rafraîchissent les deux affichages.
export function getAnnouncementsQueryOptions(selectedStoreId: number | null, role: string | undefined) {
return {
queryKey: ['/api/announcements', selectedStoreId, role],
queryFn: async (): Promise<AnnouncementWithRelations[]> => {
const params = new URLSearchParams();
// Pour les admins, permettre le filtrage par magasin sélectionné
if (role === 'admin' && selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
const url = `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(url, {
credentials: 'include'
});
if (!response.ok) {
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
if (response.status === 401) {
return [];
}
throw new Error(`Failed to fetch announcements: ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
return [];
}
return data;
},
};
}
export default function AnnouncementCard() { export default function AnnouncementCard() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -117,48 +156,9 @@ export default function AnnouncementCard() {
}, },
}); });
// Construire l'URL avec les paramètres appropriés
const buildApiUrl = () => {
const params = new URLSearchParams();
// Pour les admins, permettre le filtrage par magasin sélectionné
if (user?.role === 'admin' && selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
return `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
};
// Récupérer les annonces // Récupérer les annonces
const { data: announcements = [], isLoading } = useQuery<AnnouncementWithRelations[]>({ const { data: announcements = [], isLoading } = useQuery({
queryKey: ['/api/announcements', selectedStoreId, user?.role], ...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
queryFn: async () => {
const url = buildApiUrl();
console.log('🔍 [ANNOUNCEMENTS] Fetching from:', url);
const response = await fetch(url, {
credentials: 'include'
});
if (!response.ok) {
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
if (response.status === 401) {
console.log('🔑 [ANNOUNCEMENTS] Authentication required');
return [];
}
throw new Error(`Failed to fetch announcements: ${response.status}`);
}
const data = await response.json();
console.log('✅ [ANNOUNCEMENTS] Data received:', data);
if (!Array.isArray(data)) {
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
return [];
}
return data;
},
enabled: !!user, enabled: !!user,
}); });
@@ -171,8 +171,6 @@ export default function AnnouncementCard() {
const method = editingAnnouncement ? 'PUT' : 'POST'; const method = editingAnnouncement ? 'PUT' : 'POST';
console.log(`🔍 [ANNOUNCEMENTS] ${method} ${url}`, announcementData);
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -241,8 +239,6 @@ export default function AnnouncementCard() {
// Soumission du formulaire // Soumission du formulaire
const onSubmit = (data: InsertAnnouncement) => { const onSubmit = (data: InsertAnnouncement) => {
console.log('🔍 [ANNOUNCEMENTS] Form submitted:', data);
const announcementData = { const announcementData = {
...data, ...data,
createdBy: user?.username || '', createdBy: user?.username || '',
+119 -150
View File
@@ -1,10 +1,9 @@
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, isToday } from "date-fns"; import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
import { fr } from "date-fns/locale";
import { safeDate } from "@/lib/dateUtils"; import { safeDate } from "@/lib/dateUtils";
import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react"; import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useState } from "react"; import { memo, useMemo, useState } from "react";
// Composant modal pour afficher les commentaires // Composant modal pour afficher les commentaires
function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) { function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) {
@@ -353,7 +352,10 @@ function DayItemsContainer({ dayOrders, dayDeliveries, onItemClick }: { dayOrder
); );
} }
export default function CalendarGrid({ // Liste vide partagée pour les jours sans élément (référence stable)
const NO_ITEMS: any[] = [];
function CalendarGrid({
currentDate, currentDate,
orders, orders,
deliveries, deliveries,
@@ -363,102 +365,11 @@ export default function CalendarGrid({
onDateClick, onDateClick,
onItemClick, onItemClick,
}: CalendarGridProps) { }: CalendarGridProps) {
if (import.meta.env.DEV) { // Jours affichés : 6 semaines complètes commençant un lundi
console.log('🗓️ CalendarGrid rendered with:', { const paddedDays = useMemo(() => {
currentDate: currentDate?.toISOString(),
ordersCount: orders?.length || 0,
deliveriesCount: deliveries?.length || 0,
publicitiesCount: publicities?.length || 0,
selectedStoreId,
userGroupsCount: userGroups?.length || 0
});
}
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate); const monthEnd = endOfMonth(currentDate);
// Helper function to filter publicities based on user's assigned stores
const getPublicitiesForDate = (date: Date) => {
const dateStr = format(date, 'yyyy-MM-dd');
// Protection contre les données invalides qui causent des plantages en production
if (!publicities || !Array.isArray(publicities)) {
console.warn('⚠️ Invalid publicities data:', publicities);
return [];
}
if (import.meta.env.DEV) {
console.log('🔍 CalendarGrid getPublicitiesForDate:', {
date: dateStr,
totalPublicities: publicities.length,
selectedStoreId,
userGroups: userGroups?.length || 0
});
}
try {
return publicities.filter(pub => {
// Vérifications de sécurité pour éviter les plantages
if (!pub || typeof pub !== 'object') {
console.warn('⚠️ Invalid publicity object:', pub);
return false;
}
// Check if the date is within the publicity period
const pubStart = pub.startDate;
const pubEnd = pub.endDate;
if (!pubStart || !pubEnd) {
console.warn('⚠️ Publicity missing dates:', { pubNumber: pub.pubNumber, startDate: pubStart, endDate: pubEnd });
return false;
}
if (dateStr < pubStart || dateStr > pubEnd) {
return false;
}
// If no store is selected and user has no assigned groups, show only publicities with participations
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
if (hasParticipations && import.meta.env.DEV) {
console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
}
return hasParticipations;
}
// If a specific store is selected, check if that store participates
if (selectedStoreId) {
const matches = pub.participations && Array.isArray(pub.participations) &&
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
if (matches && import.meta.env.DEV) {
console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId });
}
return matches;
}
// If no specific store selected but user has assigned stores,
// show publicities where any of user's stores participate
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
const matches = pub.participations && Array.isArray(pub.participations) &&
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
if (matches) {
console.log('👥 Publicity matches user groups:', { pubNumber: pub.pubNumber, userGroupIds });
}
return matches;
}
// Default case: only show publicities with participations
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
if (hasParticipations) {
console.log('📋 Publicity has participations (default case):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
}
return hasParticipations;
});
} catch (error) {
console.error('❌ Error filtering publicities:', error);
return [];
}
};
// Get all days in the month // Get all days in the month
const monthDays = eachDayOfInterval({ const monthDays = eachDayOfInterval({
start: monthStart, start: monthStart,
@@ -468,91 +379,143 @@ export default function CalendarGrid({
// Pad the calendar to start on Monday // Pad the calendar to start on Monday
const firstDayOfWeek = monthStart.getDay(); const firstDayOfWeek = monthStart.getDay();
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
const paddedDays = []; const days: Date[] = [];
// Add padding days from previous month // Add padding days from previous month
for (let i = startPadding; i > 0; i--) { for (let i = startPadding; i > 0; i--) {
const paddingDate = new Date(monthStart); const paddingDate = new Date(monthStart);
paddingDate.setDate(paddingDate.getDate() - i); paddingDate.setDate(paddingDate.getDate() - i);
paddedDays.push(paddingDate); days.push(paddingDate);
} }
// Add current month days // Add current month days
paddedDays.push(...monthDays); days.push(...monthDays);
// Add padding days from next month to complete the grid // Add padding days from next month to complete the grid
const remainingCells = 42 - paddedDays.length; // 6 weeks * 7 days const remainingCells = 42 - days.length; // 6 weeks * 7 days
for (let i = 1; i <= remainingCells; i++) { for (let i = 1; i <= remainingCells; i++) {
const paddingDate = new Date(monthEnd); const paddingDate = new Date(monthEnd);
paddingDate.setDate(paddingDate.getDate() + i); paddingDate.setDate(paddingDate.getDate() + i);
paddedDays.push(paddingDate); days.push(paddingDate);
} }
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; return days;
}, [currentDate]);
const getItemsForDate = (date: Date) => { // Commandes et livraisons indexées par jour ('yyyy-MM-dd', heure locale comme
// Debug: Log des commandes reçues (seulement une fois) // isSameDay) en une seule passe sur chaque liste, au lieu de filtrer toutes
if (orders.length > 0 && date.getDate() === 1) { // les listes pour chacune des 42 cases à chaque rendu
console.log('📅 CalendarGrid Debug - Orders received:', orders.length); const itemsByDay = useMemo(() => {
console.log('📅 First order structure:', orders[0]); const index = new Map<string, { orders: OrderWithRelations[]; deliveries: DeliveryWithRelations[] }>();
console.log('📅 All orders dates:', orders.map(o => ({ id: o.id, plannedDate: o.plannedDate, supplier: o.supplier?.name }))); const getDayEntry = (key: string) => {
let entry = index.get(key);
if (!entry) {
entry = { orders: [], deliveries: [] };
index.set(key, entry);
} }
return entry;
};
let invalidCount = 0;
// Debug: Log des livraisons reçues (seulement une fois) for (const order of orders) {
if (deliveries.length > 0 && date.getDate() === 1) {
console.log('🚛 CalendarGrid Debug - Deliveries received:', deliveries.length);
console.log('🚛 First delivery structure:', deliveries[0]);
console.log('🚛 All deliveries dates:', deliveries.map(d => ({ id: d.id, scheduledDate: d.scheduledDate, supplier: d.supplier?.name })));
}
const dayOrders = orders.filter(order => {
// Protection contre undefined/null // Protection contre undefined/null
if (!order || !order.supplier) { if (!order || !order.supplier) {
console.warn('📅 Invalid order found:', order); invalidCount++;
return false; continue;
} }
// Essayer plusieurs champs de date possibles // Essayer plusieurs champs de date possibles
const orderDate = safeDate(order.plannedDate || order.createdAt); const orderDate = safeDate(order.plannedDate || order.createdAt);
const matches = orderDate && isSameDay(orderDate, date); if (orderDate) {
getDayEntry(format(orderDate, 'yyyy-MM-dd')).orders.push(order);
if (matches) { }
console.log('📅 Order matches date:', {
orderId: order.id,
supplier: order.supplier?.name,
plannedDate: order.plannedDate,
matchingDate: format(date, 'yyyy-MM-dd')
});
} }
return matches; for (const delivery of deliveries) {
});
const dayDeliveries = deliveries.filter(delivery => {
// Protection contre undefined/null // Protection contre undefined/null
if (!delivery || !delivery.supplier) { if (!delivery || !delivery.supplier) {
console.warn('🚛 Invalid delivery found:', delivery); invalidCount++;
continue;
}
// Essayer plusieurs champs de date possibles
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
if (deliveryDate) {
getDayEntry(format(deliveryDate, 'yyyy-MM-dd')).deliveries.push(delivery);
}
}
if (invalidCount > 0) {
console.warn(`⚠️ CalendarGrid: ${invalidCount} élément(s) sans fournisseur ignoré(s)`);
}
return index;
}, [orders, deliveries]);
// Publicités par jour affiché : filtrage par magasin fait une seule fois,
// puis répartition sur les 42 jours selon la période de chaque publicité
const publicitiesByDay = useMemo(() => {
const byDay = new Map<string, any[]>();
// Protection contre les données invalides qui causent des plantages en production
if (!publicities || !Array.isArray(publicities)) {
console.warn('⚠️ Invalid publicities data:', publicities);
return byDay;
}
try {
let invalidCount = 0;
const storePublicities = publicities.filter(pub => {
// Vérifications de sécurité pour éviter les plantages
if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) {
invalidCount++;
return false; return false;
} }
// Essayer plusieurs champs de date possibles // If no store is selected and user has no assigned groups, show only publicities with participations
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt); if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
const matches = deliveryDate && isSameDay(deliveryDate, date); return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
if (matches) {
console.log('🚛 Delivery matches date:', {
deliveryId: delivery.id,
supplier: delivery.supplier?.name,
scheduledDate: delivery.scheduledDate,
matchingDate: format(date, 'yyyy-MM-dd')
});
} }
return matches; // If a specific store is selected, check if that store participates
if (selectedStoreId) {
return pub.participations && Array.isArray(pub.participations) &&
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
}
// If no specific store selected but user has assigned stores,
// show publicities where any of user's stores participate
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
return pub.participations && Array.isArray(pub.participations) &&
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
}
// Default case: only show publicities with participations
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
}); });
return { orders: dayOrders, deliveries: dayDeliveries }; if (invalidCount > 0) {
}; console.warn(`⚠️ CalendarGrid: ${invalidCount} publicité(s) invalide(s) ou sans dates ignorée(s)`);
}
if (storePublicities.length > 0) {
for (const date of paddedDays) {
const dateStr = format(date, 'yyyy-MM-dd');
// Check if the date is within the publicity period
const dayPublicities = storePublicities.filter(pub => !(dateStr < pub.startDate || dateStr > pub.endDate));
if (dayPublicities.length > 0) {
byDay.set(dateStr, dayPublicities);
}
}
}
} catch (error) {
console.error('❌ Error filtering publicities:', error);
byDay.clear();
}
return byDay;
}, [publicities, selectedStoreId, userGroups, paddedDays]);
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
return ( return (
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden"> <div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
@@ -576,8 +539,11 @@ export default function CalendarGrid({
const isCurrentMonth = isSameMonth(date, currentDate); const isCurrentMonth = isSameMonth(date, currentDate);
const isTodayDate = isToday(date); const isTodayDate = isToday(date);
const isWeekend = date.getDay() === 0 || date.getDay() === 6; const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const { orders: dayOrders, deliveries: dayDeliveries } = getItemsForDate(date); const dayKey = format(date, 'yyyy-MM-dd');
const dayPublicities = getPublicitiesForDate(date); const dayItems = itemsByDay.get(dayKey);
const dayOrders = dayItems ? dayItems.orders : NO_ITEMS;
const dayDeliveries = dayItems ? dayItems.deliveries : NO_ITEMS;
const dayPublicities = publicitiesByDay.get(dayKey) || NO_ITEMS;
return ( return (
<div <div
@@ -694,3 +660,6 @@ export default function CalendarGrid({
</div> </div>
); );
} }
// Mémorisé : l'ouverture d'une modale dans la page Calendrier ne recalcule pas la grille
export default memo(CalendarGrid);
@@ -210,7 +210,7 @@ export function CustomerOrderDetails({ order }: CustomerOrderDetailsProps) {
<Badge <Badge
style={{ style={{
backgroundColor: order.group.color + "20", backgroundColor: order.group.color + "20",
color: order.group.color, color: order.group.color || undefined,
border: `1px solid ${order.group.color}30` border: `1px solid ${order.group.color}30`
}} }}
> >
+3 -2
View File
@@ -42,13 +42,14 @@ const customerOrderFormSchema = z.object({
customerNotified: z.boolean().default(false), customerNotified: z.boolean().default(false),
notes: z.string().optional(), notes: z.string().optional(),
customerEmail: z.string().optional(), customerEmail: z.string().optional(),
status: z.string().optional(),
}); });
type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>; type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>;
interface CustomerOrderFormProps { interface CustomerOrderFormProps {
order?: CustomerOrderWithRelations; order?: CustomerOrderWithRelations;
onSubmit: (data: CustomerOrderFormData) => void; onSubmit: (data: Record<string, any>) => void;
onCancel: () => void; onCancel: () => void;
isLoading?: boolean; isLoading?: boolean;
} }
@@ -113,7 +114,7 @@ export function CustomerOrderForm({
quantity: order?.quantity || 1, quantity: order?.quantity || 1,
supplierId: order?.supplierId || 1, supplierId: order?.supplierId || 1,
status: "En attente de Commande", // Statut fixe status: "En attente de Commande", // Statut fixe
deposit: order?.deposit || 0, deposit: order?.deposit ? Number(order.deposit) : 0,
isPromotionalPrice: order?.isPromotionalPrice || false, isPromotionalPrice: order?.isPromotionalPrice || false,
customerNotified: order?.customerNotified || false, customerNotified: order?.customerNotified || false,
groupId: order?.groupId || getUserAssignedGroupId() || 1, groupId: order?.groupId || getUserAssignedGroupId() || 1,
+5 -6
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -39,13 +40,9 @@ interface DlcAlertModalProps {
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) { export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [, setLocation] = useLocation();
const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null); const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null);
// Invalider les queries DLC quand le magasin change
useEffect(() => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
}, [selectedStoreId, queryClient]);
// Fetch detailed DLC products for the modal // Fetch detailed DLC products for the modal
const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({ const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({
queryKey: ["/api/dlc-products", "expires", selectedStoreId], queryKey: ["/api/dlc-products", "expires", selectedStoreId],
@@ -138,8 +135,10 @@ export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: Dl
markStockEpuiseMutation.mutate(productId); markStockEpuiseMutation.mutate(productId);
}; };
// Navigation interne : pas de rechargement complet de l'application
const handleViewDlcModule = () => { const handleViewDlcModule = () => {
window.location.href = '/dlc'; setLocation('/dlc');
onClose();
}; };
return ( return (
+74 -11
View File
@@ -1,4 +1,4 @@
import { ReactNode, useState, useEffect } from "react"; import { ReactNode, Suspense, useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -9,8 +9,10 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useScreenSize } from "@/hooks/use-screen-size"; import { useScreenSize } from "@/hooks/use-screen-size";
import { StoreProvider } from "@/contexts/StoreContext"; import { StoreProvider } from "@/contexts/StoreContext";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import ErrorBoundary from "./ErrorBoundary";
import WeatherWidget from "./WeatherWidget"; import WeatherWidget from "./WeatherWidget";
import DateWidget from "./DateWidget"; import DateWidget from "./DateWidget";
import { getNavItemForPath } from "@/lib/navigation";
import type { Group } from "@shared/schema"; import type { Group } from "@shared/schema";
@@ -18,6 +20,23 @@ interface LayoutProps {
children: ReactNode; children: ReactNode;
} }
// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
// rendu tant que /api/groups n'a pas répondu
const EMPTY_STORES: Group[] = [];
// Affiché dans la zone de contenu pendant le chargement du code d'une page
// (le menu et l'en-tête restent visibles)
function PageFallback() {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const [location] = useLocation(); const [location] = useLocation();
@@ -45,11 +64,41 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { data: stores = [] } = useQuery<Group[]>({ // Titre de l'onglet du navigateur tiré du menu : « Livraisons — LogiFlow »
useEffect(() => {
const navItem = getNavItemForPath(location);
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
}, [location]);
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
enabled: !!user, enabled: !!user,
}); });
// Changement de magasin demandé dans le sélecteur : les données dépendantes du
// magasin sont invalidées après le rendu (effet ci-dessous) et non dans le
// gestionnaire. À ce moment les pages ont déjà basculé sur les clés du nouveau
// magasin : les requêtes de l'ancien magasin ne sont plus actives et ne sont
// donc plus rechargées pour rien, et cancelRefetch: false réutilise les
// requêtes du nouveau magasin déjà en cours. L'invalidation est conservée pour
// les requêtes dont la clé ne contient pas le magasin (ex. ['/api/orders'] des
// modales de livraison).
const storeChangePendingRef = useRef(false);
useEffect(() => {
if (!storeChangePendingRef.current) return;
storeChangePendingRef.current = false;
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return Boolean(key[0]?.toString().includes('/api/orders') ||
key[0]?.toString().includes('/api/deliveries') ||
key[0]?.toString().includes('/api/stats/monthly') ||
key[0]?.toString().includes('/api/tasks'));
}
}, { cancelRefetch: false });
}, [selectedStoreId, queryClient]);
// Effet pour marquer l'initialisation comme terminée // Effet pour marquer l'initialisation comme terminée
useEffect(() => { useEffect(() => {
if (user && stores.length > 0) { if (user && stores.length > 0) {
@@ -82,8 +131,21 @@ export default function Layout({ children }: LayoutProps) {
window.location.href = "/api/logout"; window.location.href = "/api/logout";
}; };
// Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
// re-rendus que lorsqu'une de ces valeurs change réellement
const storeContextValue = useMemo(() => ({
selectedStoreId,
setSelectedStoreId,
stores,
sidebarCollapsed,
setSidebarCollapsed,
mobileMenuOpen,
setMobileMenuOpen,
storeInitialized
}), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
return ( return (
<StoreProvider value={{ selectedStoreId, setSelectedStoreId, stores, sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen, storeInitialized }}> <StoreProvider value={storeContextValue}>
<div className="layout-container flex bg-gray-50"> <div className="layout-container flex bg-gray-50">
{/* Mobile overlay for normal mode */} {/* Mobile overlay for normal mode */}
{isMobile && mobileMenuOpen && ( {isMobile && mobileMenuOpen && (
@@ -135,15 +197,10 @@ export default function Layout({ children }: LayoutProps) {
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")} value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
onValueChange={(value) => { onValueChange={(value) => {
const newStoreId = value === "all" ? null : parseInt(value); const newStoreId = value === "all" ? null : parseInt(value);
queryClient.invalidateQueries({ // Invalidation des données du magasin après le rendu (voir plus haut)
predicate: (query) => { if (newStoreId !== selectedStoreId) {
const key = query.queryKey; storeChangePendingRef.current = true;
return Boolean(key[0]?.toString().includes('/api/orders') ||
key[0]?.toString().includes('/api/deliveries') ||
key[0]?.toString().includes('/api/stats/monthly') ||
key[0]?.toString().includes('/api/tasks'));
} }
});
// Sauvegarder dans localStorage et mettre à jour l'état // Sauvegarder dans localStorage et mettre à jour l'état
if (newStoreId) { if (newStoreId) {
@@ -210,7 +267,13 @@ export default function Layout({ children }: LayoutProps) {
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6' <div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
}`} style={{ maxWidth: '100%' }}> }`} style={{ maxWidth: '100%' }}>
{/* Une erreur dans une page (ou l'échec du chargement de son fichier JS)
n'efface plus le menu ; réinitialisée à chaque changement d'URL */}
<ErrorBoundary key={location}>
<Suspense fallback={<PageFallback />}>
{children} {children}
</Suspense>
</ErrorBoundary>
</div> </div>
</main> </main>
-148
View File
@@ -1,148 +0,0 @@
import { Home, Package, Truck, ClipboardList, MoreHorizontal } from "lucide-react"
import { Link, useLocation } from "wouter"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import type { User } from "@shared/schema"
interface NavItem {
path: string
label: string
icon: any
primary?: boolean
}
interface MoreMenuItem {
path: string
label: string
icon: any
roles?: string[]
}
const primaryNavItems: NavItem[] = [
{ path: "/", label: "Accueil", icon: Home, primary: true },
{ path: "/orders", label: "Commandes", icon: Package, primary: true },
{ path: "/deliveries", label: "Livraisons", icon: Truck, primary: true },
{ path: "/tasks", label: "Tâches", icon: ClipboardList, primary: true },
]
const moreMenuItems: MoreMenuItem[] = [
{ path: "/bl-reconciliation", label: "Rapprochement", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
{ path: "/publicities", label: "Publicités", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/customer-orders", label: "Commandes Clients", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/avoirs", label: "Avoirs", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/dlc", label: "DLC", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/sav", label: "SAV", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
{ path: "/calendar", label: "Calendrier", icon: ClipboardList },
]
interface PhoneBottomNavProps {
user: User
location: string
}
export default function PhoneBottomNav({ user, location }: PhoneBottomNavProps) {
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
const isActive = (path: string) => {
if (path === "/") return location === "/"
return location.startsWith(path)
}
const hasPermission = (roles?: string[]) => {
if (!roles || !user) return true
return roles.includes(user.role)
}
const filteredMoreItems = moreMenuItems.filter(item => hasPermission(item.roles))
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-pb bottom-nav">
<div className="flex items-center justify-around py-2 px-1">
{/* Navigation principale */}
{primaryNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link key={item.path} href={item.path}>
<div
className={`flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 ${
active
? 'bg-blue-50 text-blue-600'
: 'text-gray-600 hover:bg-gray-50'
}`}
data-testid={`nav-${item.path.replace('/', 'home')}`}
>
<Icon className={`w-5 h-5 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-xs mt-1 font-medium ${
active ? 'text-blue-600' : 'text-gray-600'
}`}>
{item.label}
</span>
</div>
</Link>
)
})}
{/* Menu "Plus" */}
<Sheet open={moreSheetOpen} onOpenChange={setMoreSheetOpen}>
<SheetTrigger asChild>
<div
className="flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 text-gray-600 hover:bg-gray-50 cursor-pointer"
data-testid="nav-more"
>
<MoreHorizontal className="w-5 h-5 text-gray-600" />
<span className="text-xs mt-1 font-medium text-gray-600">Plus</span>
</div>
</SheetTrigger>
<SheetContent side="bottom" className="h-auto max-h-[60vh] rounded-t-lg">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>
Accédez aux autres sections de l'application
</SheetDescription>
</SheetHeader>
<div className="grid grid-cols-3 gap-4 mt-6 pb-4">
{filteredMoreItems.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link
key={item.path}
href={item.path}
onClick={() => setMoreSheetOpen(false)}
>
<div
className={`flex flex-col items-center justify-center p-4 rounded-lg transition-all duration-200 min-h-[80px] ${
active
? 'bg-blue-50 text-blue-600 border-2 border-blue-200'
: 'bg-gray-50 text-gray-700 hover:bg-gray-100 border-2 border-transparent'
}`}
data-testid={`more-nav-${item.path.replace('/', '')}`}
>
<Icon className={`w-6 h-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-sm mt-2 font-medium text-center ${
active ? 'text-blue-600' : 'text-gray-700'
}`}>
{item.label}
</span>
</div>
</Link>
)
})}
</div>
</SheetContent>
</Sheet>
</div>
</div>
)
}
@@ -40,13 +40,36 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
const [newComment, setNewComment] = useState(""); const [newComment, setNewComment] = useState("");
const [editComment, setEditComment] = useState(""); const [editComment, setEditComment] = useState("");
const commentsQueryKey = [`/api/deliveries/${deliveryId}/reconciliation-comments`];
// Récupérer les commentaires // Récupérer les commentaires
const { data: comments = [], isLoading } = useQuery({ const { data: comments = [], isLoading } = useQuery({
queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`], queryKey: commentsQueryKey,
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`), queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
enabled: !!deliveryId, enabled: !!deliveryId,
}); });
// Compteur de commentaires affiché dans le rapprochement : mis à jour
// localement dans la liste en cache (même clé que BLReconciliation) plutôt
// que de retélécharger toute la liste des livraisons pour un compteur.
// Les listes qui ne contiennent pas la livraison ne sont pas touchées
// (undefined : aucune écriture, leur état « périmé » éventuel est conservé).
const adjustCommentsCount = (delta: number) => {
queryClient.setQueriesData({ queryKey: ['/api/deliveries', 'reconciliation'] }, (old: unknown) => {
if (!Array.isArray(old) || !old.some((delivery: any) => delivery?.id === deliveryId)) {
return undefined;
}
return old.map((delivery: any) =>
delivery?.id === deliveryId
? {
...delivery,
reconciliationCommentsCount: Math.max(0, (Number(delivery.reconciliationCommentsCount) || 0) + delta),
}
: delivery
);
});
};
// Créer un commentaire // Créer un commentaire
const createCommentMutation = useMutation({ const createCommentMutation = useMutation({
mutationFn: async (content: string) => { mutationFn: async (content: string) => {
@@ -57,10 +80,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès", title: "Succès",
description: "Commentaire ajouté avec succès", description: "Commentaire ajouté avec succès",
}); });
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] }); queryClient.invalidateQueries({ queryKey: commentsQueryKey });
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires adjustCommentsCount(1);
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
setIsAdding(false); setIsAdding(false);
setNewComment(""); setNewComment("");
}, },
@@ -83,10 +104,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès", title: "Succès",
description: "Commentaire modifié avec succès", description: "Commentaire modifié avec succès",
}); });
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] }); // Le nombre de commentaires ne change pas : la liste des livraisons reste valable
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires queryClient.invalidateQueries({ queryKey: commentsQueryKey });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
setEditingId(null); setEditingId(null);
}, },
onError: (error: any) => { onError: (error: any) => {
@@ -108,10 +127,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès", title: "Succès",
description: "Commentaire supprimé avec succès", description: "Commentaire supprimé avec succès",
}); });
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] }); queryClient.invalidateQueries({ queryKey: commentsQueryKey });
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires adjustCommentsCount(-1);
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
+83 -85
View File
@@ -1,47 +1,79 @@
import { useEffect } from "react"; import { lazy, Suspense, useEffect, type ComponentType } from "react";
import { Switch, Route, useLocation } from "wouter"; import { Switch, Route, Redirect, useLocation } from "wouter";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useScreenSize } from "@/hooks/use-screen-size"; import { useScreenSize } from "@/hooks/use-screen-size";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import AuthPage from "@/pages/AuthPage"; import AuthPage from "@/pages/AuthPage";
import Dashboard from "@/pages/Dashboard"; import Dashboard from "@/pages/Dashboard";
import Calendar from "@/pages/Calendar";
import Orders from "@/pages/Orders";
import Deliveries from "@/pages/Deliveries";
import Suppliers from "@/pages/Suppliers";
import Groups from "@/pages/Groups";
import Users from "@/pages/Users";
import BLReconciliation from "@/pages/BLReconciliation";
import Publicities from "@/pages/Publicities";
import NocoDBConfig from "@/pages/NocoDBConfig";
import DatabaseDebug from "@/pages/DatabaseDebug";
import CustomerOrders from "@/pages/CustomerOrders";
import DlcPage from "@/pages/DlcPage";
import BackupManager from "@/pages/BackupManager";
import Utilities from "@/pages/Utilities";
import Tasks from "@/pages/Tasks";
import SavTickets from "@/pages/SavTickets";
import Avoirs from "@/pages/Avoirs";
import WeatherSettings from "@/pages/WeatherSettings";
import Analytics from "@/pages/Analytics";
import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
import Contacts from "@/pages/Contacts";
import Layout from "@/components/Layout"; import Layout from "@/components/Layout";
// Mobile pages // Mobile : coque et page d'accueil chargées immédiatement
import MobileApp from "@/pages/mobile/MobileApp"; import MobileApp from "@/pages/mobile/MobileApp";
import MobileDashboardPage from "@/pages/mobile/DashboardPage"; import MobileDashboardPage from "@/pages/mobile/DashboardPage";
import MobileOrdersPage from "@/pages/mobile/OrdersPage"; import MobileLayout from "@/pages/mobile/MobileLayout";
import MobileDeliveriesPage from "@/pages/mobile/DeliveriesPage";
import MobileCalendarPage from "@/pages/mobile/CalendarPage"; // Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
import MobileTasksPage from "@/pages/mobile/TasksPage"; // de connexion et le tableau de bord n'attendent plus le code de toutes les pages
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage"; // ni la bibliothèque de graphiques. Le <Suspense> est placé dans la zone de
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage"; // contenu de Layout et de MobileApp, le menu et l'en-tête restent affichés.
import MobileDlcPage from "@/pages/mobile/DlcPage"; const Calendar = lazy(() => import("@/pages/Calendar"));
import MobileSavPage from "@/pages/mobile/SavPage"; const Orders = lazy(() => import("@/pages/Orders"));
import MobileAvoirsPage from "@/pages/mobile/AvoirsPage"; const Deliveries = lazy(() => import("@/pages/Deliveries"));
const Suppliers = lazy(() => import("@/pages/Suppliers"));
const Groups = lazy(() => import("@/pages/Groups"));
const Users = lazy(() => import("@/pages/Users"));
const BLReconciliation = lazy(() => import("@/pages/BLReconciliation"));
const Publicities = lazy(() => import("@/pages/Publicities"));
const CustomerOrders = lazy(() => import("@/pages/CustomerOrders"));
const DlcPage = lazy(() => import("@/pages/DlcPage"));
const Utilities = lazy(() => import("@/pages/Utilities"));
const Tasks = lazy(() => import("@/pages/Tasks"));
const SavTickets = lazy(() => import("@/pages/SavTickets"));
const Avoirs = lazy(() => import("@/pages/Avoirs"));
const Analytics = lazy(() => import("@/pages/Analytics"));
const PaymentSchedulePage = lazy(() => import("@/pages/PaymentSchedulePage"));
const Contacts = lazy(() => import("@/pages/Contacts"));
// Pages mobiles chargées à la demande
const MobileOrdersPage = lazy(() => import("@/pages/mobile/OrdersPage"));
const MobileDeliveriesPage = lazy(() => import("@/pages/mobile/DeliveriesPage"));
const MobileCalendarPage = lazy(() => import("@/pages/mobile/CalendarPage"));
const MobileTasksPage = lazy(() => import("@/pages/mobile/TasksPage"));
const MobilePublicitiesPage = lazy(() => import("@/pages/mobile/PublicitiesPage"));
const MobileCustomerOrdersPage = lazy(() => import("@/pages/mobile/CustomerOrdersPage"));
const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
// Pages sans version mobile : affichées dans le cadre téléphone (en-tête, barre
// du bas et menu) pour ne jamais laisser une page sans navigation
function withMobileFrame(Page: ComponentType) {
return function MobileFramedPage() {
return (
<MobileLayout>
<div className="p-3">
<Suspense fallback={
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
}>
<Page />
</Suspense>
</div>
</MobileLayout>
);
};
}
const MobileFramedContacts = withMobileFrame(Contacts);
const MobileFramedSuppliers = withMobileFrame(Suppliers);
const MobileFramedGroups = withMobileFrame(Groups);
const MobileFramedUsers = withMobileFrame(Users);
const MobileFramedBLReconciliation = withMobileFrame(BLReconciliation);
const MobileFramedUtilities = withMobileFrame(Utilities);
const MobileFramedAnalytics = withMobileFrame(Analytics);
const MobileFramedPaymentSchedule = withMobileFrame(PaymentSchedulePage);
const MobileFramedNotFound = withMobileFrame(NotFound);
// Helper component to handle redirection // Helper component to handle redirection
const RedirectToAuth = () => { const RedirectToAuth = () => {
@@ -53,28 +85,9 @@ const RedirectToAuth = () => {
}; };
function RouterProduction() { function RouterProduction() {
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified(); const { isAuthenticated, isLoading, user } = useAuthUnified();
const { isMobile } = useScreenSize(); const { isMobile } = useScreenSize();
// Debug uniquement en développement
if (import.meta.env.DEV) {
console.log('🔍 RouterProduction Debug:', {
environment,
isAuthenticated,
isLoading,
hasUser: !!user,
userId: user?.id,
username: user?.username,
isMobile,
error: error?.message
});
}
// Debug minimal basé sur l'environnement
if (environment === 'production' && error && import.meta.env.DEV) {
console.error('🚨 Production Auth Error:', error);
}
// Loading state // Loading state
if (isLoading) { if (isLoading) {
return ( return (
@@ -89,9 +102,6 @@ function RouterProduction() {
// Not authenticated // Not authenticated
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) {
if (import.meta.env.DEV) {
console.log('🔐 Not authenticated, showing auth routes');
}
return ( return (
<Switch> <Switch>
<Route path="/auth" component={AuthPage} /> <Route path="/auth" component={AuthPage} />
@@ -113,33 +123,28 @@ function RouterProduction() {
<Route path="/deliveries" component={MobileDeliveriesPage} /> <Route path="/deliveries" component={MobileDeliveriesPage} />
<Route path="/tasks" component={MobileTasksPage} /> <Route path="/tasks" component={MobileTasksPage} />
{/* Pages sans version mobile - utiliser version desktop pour l'instant */} {/* Pages sans version mobile : version PC dans le cadre téléphone */}
<Route path="/contacts" component={Contacts} /> <Route path="/contacts" component={MobileFramedContacts} />
<Route path="/suppliers" component={Suppliers} /> <Route path="/suppliers" component={MobileFramedSuppliers} />
<Route path="/groups" component={Groups} /> <Route path="/groups" component={MobileFramedGroups} />
<Route path="/users" component={Users} /> <Route path="/users" component={MobileFramedUsers} />
<Route path="/bl-reconciliation" component={BLReconciliation} /> <Route path="/bl-reconciliation" component={MobileFramedBLReconciliation} />
<Route path="/publicities" component={MobilePublicitiesPage} /> <Route path="/publicities" component={MobilePublicitiesPage} />
<Route path="/customer-orders" component={MobileCustomerOrdersPage} /> <Route path="/customer-orders" component={MobileCustomerOrdersPage} />
<Route path="/dlc" component={MobileDlcPage} /> <Route path="/dlc" component={MobileDlcPage} />
<Route path="/utilities" component={Utilities} /> <Route path="/utilities" component={MobileFramedUtilities} />
<Route path="/sav" component={MobileSavPage} /> <Route path="/sav" component={MobileSavPage} />
<Route path="/avoirs" component={MobileAvoirsPage} /> <Route path="/avoirs" component={MobileAvoirsPage} />
<Route path="/analytics" component={Analytics} /> <Route path="/analytics" component={MobileFramedAnalytics} />
<Route path="/payment-schedule" component={PaymentSchedulePage} /> <Route path="/payment-schedule" component={MobileFramedPaymentSchedule} />
{/* Redirection /auth vers dashboard */} {/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
<Route path="/auth"> <Route path="/auth">
{() => { <Redirect to="/dashboard" replace />
if (typeof window !== 'undefined') {
window.location.href = '/dashboard';
}
return <MobileDashboardPage />;
}}
</Route> </Route>
<Route path="/" component={MobileDashboardPage} /> <Route path="/" component={MobileDashboardPage} />
<Route component={NotFound} /> <Route component={MobileFramedNotFound} />
</Switch> </Switch>
</MobileApp> </MobileApp>
); );
@@ -175,17 +180,10 @@ function RouterProduction() {
<Route path="/database-debug" component={Utilities} /> <Route path="/database-debug" component={Utilities} />
<Route path="/weather-settings" component={Utilities} /> <Route path="/weather-settings" component={Utilities} />
{/* Redirection depuis /auth vers dashboard après authentification */} {/* Redirection depuis /auth vers dashboard après authentification
(navigation interne, sans rechargement complet de l'application) */}
<Route path="/auth"> <Route path="/auth">
{() => { <Redirect to="/" replace />
if (typeof window !== 'undefined') {
if (import.meta.env.DEV) {
console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
}
window.location.href = '/';
}
return <Dashboard />;
}}
</Route> </Route>
<Route path="/" component={Dashboard} /> <Route path="/" component={Dashboard} />
File diff suppressed because it is too large. Load diff
+8 -5
View File
@@ -1,8 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, Package, Truck, Clock } from "lucide-react"; import { TrendingUp, Package, Truck, Clock } from "lucide-react";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
interface StatsPanelProps { interface StatsPanelProps {
currentDate?: Date; currentDate?: Date;
@@ -10,14 +9,13 @@ interface StatsPanelProps {
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) { export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
const { user } = useAuthUnified();
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1; const month = currentDate.getMonth() + 1;
// Construire l'URL avec les paramètres // Construire l'URL avec les paramètres
const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`; const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`;
const { data: stats, isLoading } = useQuery({ const { data: stats, isLoading, isPlaceholderData } = useQuery({
queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch
queryFn: async () => { queryFn: async () => {
const response = await fetch(statsUrl, { const response = await fetch(statsUrl, {
@@ -30,6 +28,10 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
return response.json(); return response.json();
}, },
// Changement de mois : garder les chiffres affichés pendant le chargement au lieu
// du squelette. Jamais au changement de magasin (chiffres d'un autre magasin).
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
if (isLoading) { if (isLoading) {
@@ -58,7 +60,8 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
Statistiques du mois Statistiques du mois
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> {/* Chiffres du mois précédent estompés pendant le chargement du nouveau mois */}
<CardContent className={isPlaceholderData ? "opacity-50 transition-opacity" : "transition-opacity"}>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-primary flex items-center justify-center"> <div className="text-2xl font-bold text-primary flex items-center justify-center">
-1
View File
@@ -121,7 +121,6 @@ export default function WeatherWidget() {
const { data: weather, isLoading, error } = useQuery<WeatherResponse>({ const { data: weather, isLoading, error } = useQuery<WeatherResponse>({
queryKey: ['/api/weather/current'], queryKey: ['/api/weather/current'],
refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes
retry: 1,
}); });
@@ -0,0 +1,314 @@
import { useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Clock, FileUp, Send } from "lucide-react";
// URL utilisée quand aucun webhook BAP n'est configuré dans les paramètres
const DEFAULT_BAP_WEBHOOK_URL = "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d";
interface BapUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Envoi d'un bon à payer (PDF) au workflow configuré, avec fenêtre d'attente
export default function BapUploadModal({ open, onOpenChange }: BapUploadModalProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedRecipient, setSelectedRecipient] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const stopProcessingTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => stopProcessingTimer, []);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type === 'application/pdf') {
setSelectedFile(file);
} else {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF",
variant: "destructive",
});
}
};
const startProcessingTimer = () => {
stopProcessingTimer();
setProcessingSeconds(0);
timerRef.current = setInterval(() => {
setProcessingSeconds(prev => {
if (prev >= 60) {
stopProcessingTimer();
return 60;
}
return prev + 1;
});
}, 1000);
};
const handleCloseWaitingModal = () => {
setShowWaitingModal(false);
setProcessingSeconds(0);
stopProcessingTimer();
};
const clearSelectedFile = () => {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleSendBap = async () => {
if (!selectedFile || !selectedRecipient) {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF et un destinataire",
variant: "destructive",
});
return;
}
// Récupérer l'URL webhook configurée
let webhookUrl = '';
try {
const configResponse = await fetch('/api/webhook-bap-config', {
credentials: 'include'
});
if (configResponse.ok) {
const config = await configResponse.json();
webhookUrl = config?.webhookUrl;
}
} catch (error) {
console.warn('Impossible de récupérer la config webhook, utilisation URL par défaut');
}
if (!webhookUrl) {
webhookUrl = DEFAULT_BAP_WEBHOOK_URL;
}
// Fermer la fenêtre de sélection et ouvrir celle d'attente
onOpenChange(false);
setShowWaitingModal(true);
setIsUploading(true);
startProcessingTimer();
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('recipient', selectedRecipient);
formData.append('type', 'BAP');
formData.append('fileName', selectedFile.name);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
}
handleCloseWaitingModal();
toast({
title: "Succès",
description: "Fichier BAP envoyé avec succès",
});
clearSelectedFile();
setSelectedRecipient('');
} catch (error: any) {
handleCloseWaitingModal();
let errorMessage = "Impossible d'envoyer le fichier BAP";
if (error.name === 'AbortError') {
errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)";
} else if (error.message) {
errorMessage = error.message;
}
toast({
title: "Erreur",
description: errorMessage,
variant: "destructive",
});
// Réouvrir la fenêtre de sélection en cas d'erreur
onOpenChange(true);
} finally {
setIsUploading(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileUp className="h-5 w-5 text-blue-600" />
Envoyer un bon à payer (BAP)
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-sm font-semibold text-gray-800">Fichier PDF</Label>
{!selectedFile ? (
<button
type="button"
className="w-full border-2 border-dashed border-blue-300 rounded-xl p-8 bg-blue-50 hover:bg-blue-100 transition-colors group"
onClick={() => fileInputRef.current?.click()}
>
<div className="text-center space-y-4">
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-full flex items-center justify-center group-hover:bg-blue-700 transition-colors">
<FileUp className="h-8 w-8 text-white" />
</div>
<div>
<p className="text-lg font-semibold text-gray-800 mb-2">
Choisir un fichier PDF
</p>
<p className="text-sm text-gray-600">
Cliquez pour parcourir vos fichiers
</p>
</div>
</div>
</button>
) : (
<div className="border-2 border-green-300 rounded-xl p-6 bg-green-50">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 bg-green-600 rounded-full flex items-center justify-center">
<FileUp className="h-6 w-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-green-800 truncate">{selectedFile.name}</p>
<p className="text-sm text-green-700">
{(selectedFile.size / 1024 / 1024).toFixed(2)} Mo • PDF
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={clearSelectedFile}
className="border-green-300 text-green-700 hover:bg-green-100"
>
Changer
</Button>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold text-gray-800">Envoyer à</Label>
<Select value={selectedRecipient} onValueChange={setSelectedRecipient}>
<SelectTrigger className="h-12 border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
<SelectValue placeholder="Choisir le destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Laurie" className="py-3">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-purple-600">L</span>
</div>
<span className="font-medium">Laurie</span>
</div>
</SelectItem>
<SelectItem value="Jeremy" className="py-3">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-blue-600">J</span>
</div>
<span className="font-medium">Jeremy</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 h-12 border-2 border-gray-200 hover:border-gray-300"
>
Annuler
</Button>
<Button
onClick={handleSendBap}
disabled={!selectedFile || !selectedRecipient || isUploading}
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-medium"
>
{isUploading ? (
<>
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2"></div>
Envoi en cours...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Envoyer le BAP
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Fenêtre d'attente pendant l'envoi */}
<Dialog open={showWaitingModal} onOpenChange={() => { }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Traitement en cours...
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center space-y-4 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<div className="text-sm text-gray-600">
Envoi du fichier vers le workflow...
</div>
<div className="text-sm text-gray-500">
{processingSeconds < 60
? `${processingSeconds}s écoulées`
: '60s+ écoulées'
}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -15,6 +15,9 @@ import { format } from "date-fns";
import { safeFormat } from "@/lib/dateUtils"; import { safeFormat } from "@/lib/dateUtils";
import type { Group, Supplier, OrderWithRelations } from "@shared/schema"; import type { Group, Supplier, OrderWithRelations } from "@shared/schema";
// Référence stable tant que les magasins ne sont pas chargés
const NO_GROUPS: Group[] = [];
interface CreateDeliveryModalProps { interface CreateDeliveryModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -45,20 +48,29 @@ export default function CreateDeliveryModal({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
}); });
const { data: groupsData = [] } = useQuery<Group[]>({ const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
}); });
// Filtrer les groupes selon le magasin sélectionné pour les admins // Filtrer les groupes selon le magasin sélectionné pour les admins
const groups = Array.isArray(groupsData) ? ( // (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
const groups = useMemo(() => Array.isArray(groupsData) ? (
user?.role === 'admin' && selectedStoreId user?.role === 'admin' && selectedStoreId
? groupsData.filter(g => g.id === selectedStoreId) ? groupsData.filter(g => g.id === selectedStoreId)
: groupsData : groupsData
) : []; ) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
// Filtrer les commandes par fournisseur sélectionné // Commandes du magasin du formulaire uniquement (paramètre storeId, dont
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
// les magasins ; filtrées ensuite par fournisseur
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({ const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
queryKey: ['/api/orders'], queryKey: ['/api/orders', 'store', formData.groupId],
queryFn: async () => {
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
return Array.isArray(data) ? data : [];
},
enabled: !!formData.groupId,
}); });
// Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées // Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées
@@ -74,21 +86,8 @@ export default function CreateDeliveryModal({
// Auto-sélectionner le magasin selon les règles // Auto-sélectionner le magasin selon les règles
useEffect(() => { useEffect(() => {
console.log('🏪 CreateDeliveryModal - Store selection effect:', {
groupsLength: groups.length,
currentFormGroupId: formData.groupId,
selectedStoreId,
userRole: user?.role,
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
filteredGroups: groups.length
});
// Reset le formulaire si le magasin sélectionné change // Reset le formulaire si le magasin sélectionné change
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) { if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
console.log('🔄 Resetting delivery form because store changed:', {
currentGroupId: formData.groupId,
newStoreId: selectedStoreId.toString()
});
setFormData(prev => ({ ...prev, groupId: "", orderId: "" })); setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
return; return;
} }
@@ -103,21 +102,13 @@ export default function CreateDeliveryModal({
} else { } else {
defaultGroupId = groups[0].id.toString(); defaultGroupId = groups[0].id.toString();
} }
console.log('🏪 Admin delivery store selection:', {
selectedStoreId,
defaultGroupId,
firstGroupId: groups[0].id,
groupsAvailable: groups.map(g => g.name)
});
} else { } else {
// Pour les autres rôles : prendre le premier magasin attribué // Pour les autres rôles : prendre le premier magasin attribué
// (La logique existante filtre déjà les groupes selon les permissions) // (La logique existante filtre déjà les groupes selon les permissions)
defaultGroupId = groups[0].id.toString(); defaultGroupId = groups[0].id.toString();
console.log('🏪 Non-admin delivery store selection:', { defaultGroupId, firstGroupId: groups[0].id });
} }
if (defaultGroupId) { if (defaultGroupId) {
console.log('🏪 Setting default group ID for delivery:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
setFormData(prev => ({ ...prev, groupId: defaultGroupId })); setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
} }
} }
@@ -148,7 +139,6 @@ export default function CreateDeliveryModal({
}); });
// Invalider toutes les variantes de queryKey pour assurer cohérence // Invalider toutes les variantes de queryKey pour assurer cohérence
console.log('🚚 Delivery created, clearing cache for consistency');
queryClient.invalidateQueries({ predicate: (query) => { queryClient.invalidateQueries({ predicate: (query) => {
const key = query.queryKey; const key = query.queryKey;
const firstKey = key[0]?.toString() || ''; const firstKey = key[0]?.toString() || '';
@@ -273,7 +263,7 @@ export default function CreateDeliveryModal({
<> <>
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: selectedGroup.color }} style={{ backgroundColor: selectedGroup.color || undefined }}
/> />
<span className="font-medium">{selectedGroup.name}</span> <span className="font-medium">{selectedGroup.name}</span>
</> </>
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -14,6 +14,9 @@ import { isUnauthorizedError } from "@/lib/authUtils";
import { format } from "date-fns"; import { format } from "date-fns";
import type { Group, Supplier } from "@shared/schema"; import type { Group, Supplier } from "@shared/schema";
// Référence stable tant que les magasins ne sont pas chargés
const NO_GROUPS: Group[] = [];
interface CreateOrderModalProps { interface CreateOrderModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -41,34 +44,23 @@ export default function CreateOrderModal({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
}); });
const { data: groupsData = [] } = useQuery<Group[]>({ const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
}); });
// Filtrer les groupes selon le magasin sélectionné pour les admins // Filtrer les groupes selon le magasin sélectionné pour les admins
const groups = Array.isArray(groupsData) ? ( // (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
const groups = useMemo(() => Array.isArray(groupsData) ? (
user?.role === 'admin' && selectedStoreId user?.role === 'admin' && selectedStoreId
? groupsData.filter(g => g.id === selectedStoreId) ? groupsData.filter(g => g.id === selectedStoreId)
: groupsData : groupsData
) : []; ) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
// Auto-sélectionner le magasin selon les règles // Auto-sélectionner le magasin selon les règles
useEffect(() => { useEffect(() => {
console.log('🏪 CreateOrderModal - Store selection effect:', {
groupsLength: groups.length,
currentFormGroupId: formData.groupId,
selectedStoreId,
userRole: user?.role,
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
filteredGroups: groups.length
});
// Reset le formulaire si le magasin sélectionné change // Reset le formulaire si le magasin sélectionné change
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) { if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
console.log('🔄 Resetting form because store changed:', {
currentGroupId: formData.groupId,
newStoreId: selectedStoreId.toString()
});
setFormData(prev => ({ ...prev, groupId: "" })); setFormData(prev => ({ ...prev, groupId: "" }));
return; return;
} }
@@ -83,20 +75,12 @@ export default function CreateOrderModal({
} else { } else {
defaultGroupId = groups[0].id.toString(); defaultGroupId = groups[0].id.toString();
} }
console.log('🏪 Admin store selection:', {
selectedStoreId,
defaultGroupId,
firstGroupId: groups[0].id,
groupsAvailable: groups.map(g => g.name)
});
} else { } else {
// Pour les autres rôles : prendre le premier magasin attribué // Pour les autres rôles : prendre le premier magasin attribué
defaultGroupId = groups[0].id.toString(); defaultGroupId = groups[0].id.toString();
console.log('🏪 Non-admin store selection:', { defaultGroupId, firstGroupId: groups[0].id });
} }
if (defaultGroupId) { if (defaultGroupId) {
console.log('🏪 Setting default group ID:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
setFormData(prev => ({ ...prev, groupId: defaultGroupId })); setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
} }
} }
@@ -104,9 +88,7 @@ export default function CreateOrderModal({
const createOrderMutation = useMutation({ const createOrderMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
console.log('🚀 Creating order with data:', data);
const response = await apiRequest("/api/orders", "POST", data); const response = await apiRequest("/api/orders", "POST", data);
console.log('✅ Order created successfully:', response);
return response; return response;
}, },
onSuccess: () => { onSuccess: () => {
@@ -114,8 +96,6 @@ export default function CreateOrderModal({
title: "Succès", title: "Succès",
description: "Commande créée avec succès", description: "Commande créée avec succès",
}); });
// Force un nettoyage complet du cache pour éviter incohérences
console.log('🆕 Order created, clearing cache for consistency');
// Invalider toutes les variantes de queryKey pour assurer cohérence // Invalider toutes les variantes de queryKey pour assurer cohérence
queryClient.invalidateQueries({ predicate: (query) => { queryClient.invalidateQueries({ predicate: (query) => {
@@ -210,7 +190,7 @@ export default function CreateOrderModal({
<> <>
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: selectedGroup.color }} style={{ backgroundColor: selectedGroup.color || undefined }}
/> />
<span className="font-medium">{selectedGroup.name}</span> <span className="font-medium">{selectedGroup.name}</span>
</> </>
@@ -47,8 +47,16 @@ export default function EditDeliveryModal({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
}); });
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
// les magasins ; même clé que la modale de création
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({ const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
queryKey: ['/api/orders'], queryKey: ['/api/orders', 'store', formData.groupId],
queryFn: async () => {
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
return Array.isArray(data) ? data : [];
},
enabled: !!formData.groupId,
}); });
// Filtrer les commandes par fournisseur sélectionné et groupe - montrer toutes les commandes non livrées du même groupe // Filtrer les commandes par fournisseur sélectionné et groupe - montrer toutes les commandes non livrées du même groupe
@@ -178,7 +186,7 @@ export default function EditDeliveryModal({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
/> />
<span>{group.name}</span> <span>{group.name}</span>
</div> </div>
@@ -155,7 +155,7 @@ export default function EditOrderModal({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
/> />
<span>{group.name}</span> <span>{group.name}</span>
</div> </div>
@@ -54,9 +54,9 @@ export default function ValidateDeliveryModal({
title: "Succès", title: "Succès",
description: "Livraison validée avec succès", description: "Livraison validée avec succès",
}); });
// Invalider tous les caches liés aux livraisons // Invalider tous les caches liés aux livraisons et les caches BL/Rapprochement
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); // avec toutes les variations de clés, en un seul appel (un second appel sur
// Invalider tous les caches BL/Rapprochement avec toutes les variations de clés // ['/api/deliveries'] relançait le chargement des listes affichées)
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (query) => predicate: (query) =>
query.queryKey[0] === '/api/deliveries/bl' || query.queryKey[0] === '/api/deliveries/bl' ||
+4 -1
View File
@@ -3,7 +3,10 @@ import { useState, useEffect } from "react"
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined) // Valeur calculée dès le premier rendu (évite un second rendu au montage)
const [isMobile, setIsMobile] = useState<boolean | undefined>(() =>
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
)
useEffect(() => { useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+19 -14
View File
@@ -14,26 +14,24 @@ const defaultBreakpoints: ScreenBreakpoints = {
desktop: 1280 desktop: 1280
} }
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) { const getScreenSize = (width: number, breakpoints: ScreenBreakpoints): ScreenSize => {
const [screenSize, setScreenSize] = useState<ScreenSize>('desktop')
const [dimensions, setDimensions] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
height: typeof window !== 'undefined' ? window.innerHeight : 800
})
useEffect(() => {
const getScreenSize = (width: number): ScreenSize => {
if (width < breakpoints.mobile) return 'mobile' if (width < breakpoints.mobile) return 'mobile'
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
return 'desktop' return 'desktop'
} }
const updateScreenSize = () => { export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
const width = window.innerWidth // Palier calculé dès le premier rendu (pas de passage transitoire par 'desktop')
const height = window.innerHeight const [screenSize, setScreenSize] = useState<ScreenSize>(() =>
typeof window !== 'undefined' ? getScreenSize(window.innerWidth, breakpoints) : 'desktop'
)
setDimensions({ width, height }) useEffect(() => {
setScreenSize(getScreenSize(width)) // Seul le palier est stocké : setScreenSize avec la même valeur ne provoque
// pas de re-rendu, donc l'arbre n'est re-rendu que lorsque le palier change
// et non à chaque pixel de redimensionnement
const updateScreenSize = () => {
setScreenSize(getScreenSize(window.innerWidth, breakpoints))
} }
// Set initial values // Set initial values
@@ -46,6 +44,13 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
return () => window.removeEventListener('resize', updateScreenSize) return () => window.removeEventListener('resize', updateScreenSize)
}, [breakpoints]) }, [breakpoints])
// Dimensions lues au moment du rendu (non stockées dans l'état pour ne pas
// re-rendre à chaque redimensionnement)
const dimensions = {
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
height: typeof window !== 'undefined' ? window.innerHeight : 800
}
return { return {
screenSize, screenSize,
dimensions, dimensions,
+3 -28
View File
@@ -1,28 +1,3 @@
import { useQuery } from "@tanstack/react-query"; // Conservé pour compatibilité : délègue au hook unifié (même cache React Query
// partagé, un seul GET /api/user pour toute l'application)
export function useAuth() { export { useAuthUnified as useAuth } from './useAuthUnified';
const { data: user, isLoading, error } = useQuery({
queryKey: ["/api/user"],
retry: (failureCount, error: any) => {
// Ne pas retry si c'est une erreur d'authentification
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
return false;
}
return failureCount < 2;
},
refetchInterval: false,
refetchOnWindowFocus: false,
refetchOnMount: true, // Permettre le rechargement au montage
refetchOnReconnect: false,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
enabled: true,
});
return {
user: user || null,
isLoading,
isAuthenticated: !!user,
error,
};
}
+3 -70
View File
@@ -1,70 +1,3 @@
import { useState, useEffect } from 'react'; // Conservé pour compatibilité : délègue au hook unifié (même cache React Query
// partagé, un seul GET /api/user pour toute l'application)
// Hook d'authentification spécifique pour la production export { useAuthUnified as useAuthProduction } from './useAuthUnified';
// Utilise fetch direct sans React Query pour éviter les problèmes de cache
export function useAuthProduction() {
const [user, setUser] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const checkAuth = async () => {
try {
setIsLoading(true);
setError(null);
console.log('🔄 Production auth check starting...');
const response = await fetch('/api/user', {
credentials: 'include',
cache: 'no-cache',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
console.log('🔄 Production auth response:', response.status);
if (response.ok) {
const userData = await response.json();
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
setUser(userData);
setError(null);
} else if (response.status === 401) {
console.log('❌ Production auth 401 - user not authenticated');
setUser(null);
setError(null);
} else {
throw new Error(`Auth failed: ${response.status}`);
}
} catch (err) {
console.error('❌ Production auth error:', err);
setError(err);
setUser(null);
} finally {
setIsLoading(false);
console.log('🔄 Production auth check completed');
}
};
// Authentification au montage et lors des triggers
useEffect(() => {
checkAuth();
}, [refreshTrigger]);
// Fonction pour rafraîchir l'authentification
const refreshAuth = () => {
console.log('🔄 RefreshAuth called for production');
setRefreshTrigger(prev => prev + 1);
};
return {
user,
isLoading,
isAuthenticated: !!user,
error,
refreshAuth,
environment: 'production'
};
}
+3 -63
View File
@@ -1,63 +1,3 @@
import { useState, useEffect } from 'react'; // Conservé pour compatibilité : délègue au hook unifié (même cache React Query
// partagé, un seul GET /api/user pour toute l'application)
// Hook d'authentification simplifié qui fonctionne dans tous les environnements export { useAuthUnified as useAuthSimple } from './useAuthUnified';
export function useAuthSimple() {
const [user, setUser] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(null);
const [refreshCounter, setRefreshCounter] = useState(0);
// Fonction pour vérifier l'authentification
const checkAuth = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/user', {
credentials: 'include',
cache: 'no-cache',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userData = await response.json();
console.log('✅ Auth check successful:', { username: userData?.username, id: userData?.id });
setUser(userData);
setError(null);
} else if (response.status === 401) {
console.log('❌ Auth check 401 - user not authenticated');
setUser(null);
setError(null);
} else {
throw new Error(`Auth failed: ${response.status}`);
}
} catch (err) {
console.error('Auth check error:', err);
setError(err);
setUser(null);
} finally {
setIsLoading(false);
}
};
// Fonction pour forcer un refresh de l'authentification
const refreshAuth = () => {
console.log('🔄 Refreshing authentication...');
setRefreshCounter(prev => prev + 1);
};
// Vérifier l'authentification au montage et lors des refresh
useEffect(() => {
checkAuth();
}, [refreshCounter]);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
refreshAuth
};
}
+42 -227
View File
@@ -1,242 +1,57 @@
import { useState, useEffect } from 'react'; import { useCallback } from 'react';
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AUTH_QUERY_KEY, fetchCurrentUser } from "@/lib/queryClient";
// Hook d'authentification unifié qui s'adapte automatiquement // Conservé dans le retour du hook pour compatibilité (utilisé pour le débogage)
// En production utilise fetch direct, en développement utilise React Query const environment = import.meta.env.DEV ? 'development' : 'production';
// Hook d'authentification unique (développement et production) : l'utilisateur
// connecté est lu une seule fois via GET /api/user puis partagé par toute
// l'application à travers le cache React Query (clé ['/api/user']).
// Un 401 donne user = null (non connecté), sans redirection.
export function useAuthUnified() { export function useAuthUnified() {
// Détection d'environnement plus robuste
const isDevelopment = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' ||
window.location.hostname.includes('replit.dev')) &&
import.meta.env.DEV === true;
// Debug logging uniquement en développement
if (import.meta.env.DEV) {
console.log('🔍 Auth Environment Debug:', {
hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
isDev: import.meta.env.DEV,
environment: isDevelopment ? 'development' : 'production'
});
}
// État pour la version production (fetch direct)
const [productionUser, setProductionUser] = useState<any>(null);
const [productionLoading, setProductionLoading] = useState(true);
const [productionError, setProductionError] = useState<any>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Hook React Query pour le développement
const developmentQuery = useQuery({
queryKey: ["/api/user"],
retry: (failureCount, error: any) => {
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
return false;
}
return failureCount < 2;
},
refetchInterval: false,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnReconnect: false,
staleTime: 10 * 60 * 1000, // 10 minutes de cache pour l'auth
gcTime: 15 * 60 * 1000, // 15 minutes
enabled: isDevelopment, // Seulement en développement
});
// Fonction pour rafraîchir l'authentification
const refreshAuth = () => {
if (import.meta.env.DEV) {
console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
}
if (!isDevelopment) {
if (import.meta.env.DEV) {
console.log('🔄 Triggering production auth refresh');
}
setRefreshTrigger(prev => {
const newValue = prev + 1;
if (import.meta.env.DEV) {
console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
}
return newValue;
});
} else {
if (import.meta.env.DEV) {
console.log('🔄 Development mode - using React Query refresh');
}
developmentQuery.refetch();
}
};
// Fonction pour rafraîchir de manière synchrone (pour après login)
const forceAuthRefresh = async () => {
if (import.meta.env.DEV) {
console.log('🔄 ForceAuthRefresh called, isDevelopment:', isDevelopment);
}
if (!isDevelopment) {
// En production, faire un fetch immédiat et forcer un re-render
try {
if (import.meta.env.DEV) {
console.log('🔄 Production force refresh - fetching user data');
}
setProductionLoading(true);
const response = await fetch('/api/user', {
credentials: 'include',
cache: 'no-cache',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userData = await response.json();
if (import.meta.env.DEV) {
console.log('✅ Production force refresh success:', { username: userData?.username, id: userData?.id });
}
setProductionUser(userData);
setProductionError(null);
setProductionLoading(false);
// Forcer un trigger de refresh pour déclencher les re-renders
setRefreshTrigger(prev => prev + 1);
return userData;
} else {
if (import.meta.env.DEV) {
console.log('❌ Production force refresh failed:', response.status);
}
setProductionUser(null);
setProductionError(null);
setProductionLoading(false);
return null;
}
} catch (error) {
if (import.meta.env.DEV) {
console.error('❌ Production force refresh error:', error);
}
setProductionError(error);
setProductionUser(null);
setProductionLoading(false);
return null;
}
} else {
// En développement, forcer un refetch avec invalidation du cache
if (import.meta.env.DEV) {
console.log('🔄 Development mode - invalidating cache and refetching');
}
const queryClient = useQueryClient(); const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
// Attendre un court délai pour la propagation const authQuery = useQuery<any>({
await new Promise(resolve => setTimeout(resolve, 50)); queryKey: AUTH_QUERY_KEY,
queryFn: fetchCurrentUser,
const result = await developmentQuery.refetch(); staleTime: Infinity,
if (import.meta.env.DEV) { retry: false,
console.log('🔄 Development refetch result:', { // Après une erreur serveur, ne pas relancer la requête à chaque montage d'un
success: result.isSuccess, // composant (AuthPage) : sinon boucle chargement / page de connexion
hasData: !!result.data, retryOnMount: false,
userId: result.data?.id
}); });
}
return result.data;
}
};
// Authentification production (fetch direct) // Rafraîchissement en arrière-plan : tous les composants abonnés reçoivent
useEffect(() => { // le nouvel utilisateur quand la réponse arrive
if (isDevelopment) return; // Ne pas exécuter en développement const refreshAuth = useCallback(() => {
return queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEY });
}, [queryClient]);
let isMounted = true; // Rechargement immédiat (après connexion) : attend la réponse du serveur,
// met à jour le cache partagé et renvoie l'utilisateur (ou null)
const checkAuth = async () => { const forceAuthRefresh = useCallback(async () => {
try { try {
if (import.meta.env.DEV) { return await queryClient.fetchQuery({
console.log('🔄 Production auth check starting, refreshTrigger:', refreshTrigger); queryKey: AUTH_QUERY_KEY,
} queryFn: fetchCurrentUser,
setProductionLoading(true); staleTime: 0,
const response = await fetch('/api/user', {
credentials: 'include',
cache: 'no-cache',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}); });
} catch (error) {
console.error("Erreur lors du rafraîchissement de l'authentification:", error);
return null;
}
}, [queryClient]);
if (import.meta.env.DEV) { const user = authQuery.data ?? null;
console.log('🔄 Production auth response:', response.status);
}
if (!isMounted) return;
if (response.ok) {
const userData = await response.json();
if (import.meta.env.DEV) {
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
}
if (isMounted) {
setProductionUser(userData);
setProductionError(null);
}
} else if (response.status === 401) {
if (import.meta.env.DEV) {
console.log('❌ Production auth 401 - user not authenticated');
}
if (isMounted) {
setProductionUser(null);
setProductionError(null);
}
} else {
throw new Error(`Auth failed: ${response.status}`);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error('Production auth error:', err);
}
if (isMounted) {
setProductionError(err);
setProductionUser(null);
}
} finally {
if (isMounted) {
if (import.meta.env.DEV) {
console.log('🔄 Production auth check complete, loading set to false');
}
setProductionLoading(false);
}
}
};
checkAuth();
return () => {
isMounted = false;
};
}, [isDevelopment, refreshTrigger]); // Ajout du refreshTrigger
// Retourner les bonnes données selon l'environnement
if (isDevelopment) {
return { return {
user: developmentQuery.data || null, user,
isLoading: developmentQuery.isLoading, isLoading: authQuery.isLoading,
isAuthenticated: !!developmentQuery.data, isAuthenticated: !!user,
error: developmentQuery.error, error: authQuery.error,
refreshAuth: refreshAuth, refreshAuth,
forceAuthRefresh: forceAuthRefresh, forceAuthRefresh,
environment: 'development' environment
};
} else {
return {
user: productionUser,
isLoading: productionLoading,
isAuthenticated: !!productionUser,
error: productionError,
refreshAuth: refreshAuth,
forceAuthRefresh: forceAuthRefresh,
environment: 'production'
}; };
} }
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { format } from "date-fns"; import { format, type Locale } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
/** /**
+134
View File
@@ -0,0 +1,134 @@
import type { LucideIcon } from "lucide-react";
import {
House,
ListTodo,
CalendarClock,
ShoppingCart,
Wrench,
Package,
Truck,
FileCheck,
Receipt,
CreditCard,
CalendarDays,
Megaphone,
BookUser,
ChartLine,
UserCog,
Store,
Building2,
Settings,
} from "lucide-react";
// Configuration unique du menu de navigation (docs/PLAN-OPTIMISATION-WEBUI.md, P1 § 2.1).
// Chaque icône n'apparaît qu'une fois ; les adresses sont inchangées.
export type Role = "admin" | "directeur" | "manager" | "employee";
const ALL_ROLES: Role[] = ["admin", "directeur", "manager", "employee"];
export interface NavItem {
path: string;
label: string;
icon: LucideIcon;
roles: Role[];
}
export interface NavSection {
// Pas de titre pour les entrées Accueil et Calendrier, affichées en tête
title?: string;
items: NavItem[];
}
export const NAV_SECTIONS: NavSection[] = [
{
items: [
{ path: "/", label: "Accueil", icon: House, roles: ALL_ROLES },
{ path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
],
},
{
title: "Au quotidien",
items: [
{ path: "/tasks", label: "Tâches", icon: ListTodo, roles: ALL_ROLES },
{ path: "/dlc", label: "DLC", icon: CalendarClock, roles: ALL_ROLES },
{ path: "/customer-orders", label: "Commandes clients", icon: ShoppingCart, roles: ALL_ROLES },
{ path: "/sav", label: "SAV", icon: Wrench, roles: ALL_ROLES },
],
},
{
title: "Fournisseurs",
items: [
{ path: "/orders", label: "Commandes fournisseurs", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/deliveries", label: "Livraisons", icon: Truck, roles: ["admin", "directeur", "manager"] },
{ path: "/bl-reconciliation", label: "Rapprochement BL / factures", icon: FileCheck, roles: ["admin", "directeur"] },
{ path: "/avoirs", label: "Avoirs", icon: Receipt, roles: ["admin", "directeur", "manager"] },
{ path: "/payment-schedule", label: "Échéancier", icon: CreditCard, roles: ["admin", "directeur"] },
],
},
{
title: "Planning et infos",
items: [
{ path: "/publicities", label: "Publicités", icon: Megaphone, roles: ALL_ROLES },
{ path: "/contacts", label: "Contacts", icon: BookUser, roles: ALL_ROLES },
{ path: "/analytics", label: "Statistiques", icon: ChartLine, roles: ["admin", "directeur", "manager"] },
],
},
{
title: "Administration",
items: [
{ path: "/users", label: "Utilisateurs", icon: UserCog, roles: ["admin"] },
{ path: "/groups", label: "Magasins", icon: Store, roles: ["admin"] },
{ path: "/suppliers", label: "Fiches fournisseurs", icon: Building2, roles: ["admin"] },
{ path: "/utilities", label: "Paramètres", icon: Settings, roles: ["admin"] },
],
},
];
// Sections visibles pour un rôle, sans les sections devenues vides
export function getNavSections(role?: string): NavSection[] {
if (!role) return [];
return NAV_SECTIONS
.map((section) => ({
...section,
items: section.items.filter((item) => item.roles.includes(role as Role)),
}))
.filter((section) => section.items.length > 0);
}
// Barre du bas sur téléphone : les 4 premières entrées autorisées de cette liste,
// le reste est dans « Menu »
const MOBILE_SHORTCUT_PATHS = ["/", "/deliveries", "/tasks", "/dlc", "/customer-orders"];
const MOBILE_SHORTCUT_COUNT = 4;
export function getMobileShortcuts(role?: string): NavItem[] {
const allowed = getNavSections(role).flatMap((section) => section.items);
return MOBILE_SHORTCUT_PATHS
.map((path) => allowed.find((item) => item.path === path))
.filter((item): item is NavItem => Boolean(item))
.slice(0, MOBILE_SHORTCUT_COUNT);
}
// « / » et « /dashboard » activent tous deux Accueil ; les autres entrées
// restent actives sur leurs sous-adresses
export function isNavItemActive(itemPath: string, location: string): boolean {
if (itemPath === "/") return location === "/" || location === "/dashboard";
return location === itemPath || location.startsWith(`${itemPath}/`);
}
export function getNavItemForPath(location: string): NavItem | undefined {
for (const section of NAV_SECTIONS) {
const item = section.items.find((navItem) => isNavItemActive(navItem.path, location));
if (item) return item;
}
return undefined;
}
export function getRoleLabel(role?: string): string {
switch (role) {
case "admin": return "Administrateur";
case "directeur": return "Directeur";
case "manager": return "Manager";
default: return "Employé";
}
}
+45 -2
View File
@@ -66,6 +66,26 @@ export const getQueryFn: <T>(options: {
return await res.json(); return await res.json();
}; };
// Clé de cache de l'utilisateur connecté, partagée par toute l'application
export const AUTH_QUERY_KEY = ["/api/user"] as const;
// Lecture de l'utilisateur connecté. Un 401 signifie simplement « non connecté » :
// on renvoie null sans rediriger (sinon boucle sur la page de connexion).
export async function fetchCurrentUser(): Promise<any> {
const res = await fetch("/api/user", {
credentials: "include",
cache: "no-cache",
headers: { Accept: "application/json" },
});
if (res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
}
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -77,15 +97,22 @@ export const queryClient = new QueryClient({
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error: any) => { retry: (failureCount, error: any) => {
const message: string = error?.message ?? '';
// Ne pas retry les erreurs d'authentification et rediriger // Ne pas retry les erreurs d'authentification et rediriger
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) { if (message.includes('401') || message.includes('Unauthorized')) {
if (typeof window !== 'undefined' && window.location.pathname !== '/auth') { if (typeof window !== 'undefined' && window.location.pathname !== '/auth') {
console.log('🔄 Query 401 - Redirecting to auth page'); console.log('🔄 Query 401 - Redirecting to auth page');
window.location.href = '/auth'; window.location.href = '/auth';
} }
return false; return false;
} }
return failureCount < 2; // Erreur côté client (403 droit refusé, 404...) : un nouvel essai
// donnerait le même résultat et retarderait l'affichage de l'erreur
if (/^4\d\d\b/.test(message)) {
return false;
}
// Erreur serveur ou réseau : un seul nouvel essai
return failureCount < 1;
}, },
}, },
mutations: { mutations: {
@@ -93,3 +120,19 @@ export const queryClient = new QueryClient({
}, },
}, },
}); });
// Données de référence qui changent rarement (les mutations fournisseurs et
// magasins invalident déjà ces clés) : pas de rechargement à chaque ouverture
// de page ou de modale.
queryClient.setQueryDefaults(["/api/suppliers"], { staleTime: 5 * 60 * 1000 });
queryClient.setQueryDefaults(["/api/groups"], { staleTime: 5 * 60 * 1000 });
// Utilisateur connecté : chargé une seule fois pour toute l'application, puis
// rafraîchi explicitement (connexion, refreshAuth). Pas de nouvel essai, ni au
// montage d'un composant : un échec équivaut à « non connecté ».
queryClient.setQueryDefaults(AUTH_QUERY_KEY, {
queryFn: fetchCurrentUser,
staleTime: Infinity,
retry: false,
retryOnMount: false,
});
+17
View File
@@ -2,4 +2,21 @@ import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
// Les pages sont chargées à la demande : après une mise en production, les
// anciens fichiers JS n'existent plus sur le serveur et le chargement d'une page
// échoue. On recharge alors l'application une seule fois pour récupérer la
// nouvelle version (au plus une fois par minute, pour ne pas boucler si le
// serveur est indisponible ; l'erreur est alors affichée normalement).
window.addEventListener("vite:preloadError", () => {
const storageKey = "logiflow-chunk-reload-at";
try {
const lastReload = Number(sessionStorage.getItem(storageKey) || 0);
if (Date.now() - lastReload < 60 * 1000) return;
sessionStorage.setItem(storageKey, String(Date.now()));
} catch {
return;
}
window.location.reload();
});
createRoot(document.getElementById("root")!).render(<App />); createRoot(document.getElementById("root")!).render(<App />);
+33 -22
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -52,11 +52,13 @@ export default function Analytics() {
return params.toString(); return params.toString();
}, [dateRange, selectedSuppliers, selectedStores, selectedStatus]); }, [dateRange, selectedSuppliers, selectedStores, selectedStatus]);
// Récupération des données // Récupération des données. Au changement de filtre (période, fournisseur,
const { data: summary, isLoading: summaryLoading } = useQuery({ // magasin, granularité), les résultats précédents restent affichés, atténués
// et signalés par « Mise à jour… », au lieu de remplacer toute la page par un
// spinner : le chargement plein écran ne concerne que le premier affichage.
const { data: summary, isLoading: summaryLoading, isPlaceholderData: summaryUpdating } = useQuery({
queryKey: ['/api/analytics/summary', queryParams], queryKey: ['/api/analytics/summary', queryParams],
queryFn: async () => { queryFn: async () => {
console.log('📊 [ANALYTICS] Fetching summary with params:', queryParams);
const response = await fetch(`/api/analytics/summary?${queryParams}`, { const response = await fetch(`/api/analytics/summary?${queryParams}`, {
credentials: 'include' credentials: 'include'
}); });
@@ -64,18 +66,16 @@ export default function Analytics() {
console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText); console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText);
throw new Error('Failed to fetch summary'); throw new Error('Failed to fetch summary');
} }
const data = await response.json(); return response.json();
console.log('✅ [ANALYTICS] Summary data received:', data);
return data;
}, },
placeholderData: keepPreviousData,
refetchInterval: isRefreshing ? 30000 : false refetchInterval: isRefreshing ? 30000 : false
}); });
const { data: timeseries } = useQuery({ const { data: timeseries, isPlaceholderData: timeseriesUpdating } = useQuery({
queryKey: ['/api/analytics/timeseries', queryParams, granularity], queryKey: ['/api/analytics/timeseries', queryParams, granularity],
queryFn: async () => { queryFn: async () => {
const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`; const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`;
console.log('📊 [ANALYTICS] Fetching timeseries:', url);
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include' credentials: 'include'
}); });
@@ -83,13 +83,12 @@ export default function Analytics() {
console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText); console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText);
throw new Error('Failed to fetch timeseries'); throw new Error('Failed to fetch timeseries');
} }
const data = await response.json(); return response.json();
console.log('✅ [ANALYTICS] Timeseries data received:', data?.length || 0, 'entries'); },
return data; placeholderData: keepPreviousData,
}
}); });
const { data: bySupplier } = useQuery({ const { data: bySupplier, isPlaceholderData: bySupplierUpdating } = useQuery({
queryKey: ['/api/analytics/by-supplier', queryParams], queryKey: ['/api/analytics/by-supplier', queryParams],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`/api/analytics/by-supplier?${queryParams}`, { const response = await fetch(`/api/analytics/by-supplier?${queryParams}`, {
@@ -97,10 +96,11 @@ export default function Analytics() {
}); });
if (!response.ok) throw new Error('Failed to fetch by supplier'); if (!response.ok) throw new Error('Failed to fetch by supplier');
return response.json(); return response.json();
} },
placeholderData: keepPreviousData,
}); });
const { data: byStore } = useQuery({ const { data: byStore, isPlaceholderData: byStoreUpdating } = useQuery({
queryKey: ['/api/analytics/by-store', queryParams], queryKey: ['/api/analytics/by-store', queryParams],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`/api/analytics/by-store?${queryParams}`, { const response = await fetch(`/api/analytics/by-store?${queryParams}`, {
@@ -108,9 +108,14 @@ export default function Analytics() {
}); });
if (!response.ok) throw new Error('Failed to fetch by store'); if (!response.ok) throw new Error('Failed to fetch by store');
return response.json(); return response.json();
} },
placeholderData: keepPreviousData,
}); });
const isUpdating = summaryUpdating || timeseriesUpdating || bySupplierUpdating || byStoreUpdating;
// Bloc dont les chiffres viennent encore des filtres précédents
const updatingClass = (updating: boolean) => `transition-opacity ${updating ? 'opacity-60' : ''}`;
const { data: suppliers } = useQuery({ const { data: suppliers } = useQuery({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
}); });
@@ -161,6 +166,12 @@ export default function Analytics() {
<p className="text-gray-600 mt-1">Analysez vos performances logistiques</p> <p className="text-gray-600 mt-1">Analysez vos performances logistiques</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isUpdating && (
<span className="flex items-center text-sm text-gray-500" role="status">
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
Mise à jour…
</span>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -330,7 +341,7 @@ export default function Analytics() {
</Card> </Card>
{/* KPIs */} {/* KPIs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 ${updatingClass(summaryUpdating)}`}>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600"> <CardTitle className="text-sm font-medium text-gray-600">
@@ -395,7 +406,7 @@ export default function Analytics() {
{/* Graphiques */} {/* Graphiques */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Évolution temporelle */} {/* Évolution temporelle */}
<Card> <Card className={updatingClass(timeseriesUpdating)}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" /> <Activity className="h-5 w-5" />
@@ -429,7 +440,7 @@ export default function Analytics() {
</Card> </Card>
{/* Répartition par fournisseur */} {/* Répartition par fournisseur */}
<Card> <Card className={updatingClass(bySupplierUpdating)}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5" /> <PieChart className="h-5 w-5" />
@@ -471,7 +482,7 @@ export default function Analytics() {
</Card> </Card>
{/* Performance par magasin */} {/* Performance par magasin */}
<Card> <Card className={updatingClass(byStoreUpdating)}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Store className="h-5 w-5" /> <Store className="h-5 w-5" />
@@ -511,7 +522,7 @@ export default function Analytics() {
</Card> </Card>
{/* Top fournisseurs */} {/* Top fournisseurs */}
<Card> <Card className={updatingClass(summaryUpdating)}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
+10 -14
View File
@@ -62,13 +62,19 @@ export default function AuthPage() {
if (response.ok) { if (response.ok) {
toast({ toast({
title: "Connexion réussie", title: "Connexion réussie",
description: "Redirection en cours...", description: "Bienvenue dans LogiFlow",
}); });
// En production, recharger la page pour éviter les problèmes de synchronisation // L'utilisateur est relu une fois et placé dans le cache partagé : toute
setTimeout(() => { // l'application bascule directement sur le tableau de bord, sans attente
// ni rechargement complet
const refreshedUserData = await forceAuthRefresh();
if (refreshedUserData && refreshedUserData.id) {
setLocation("/", { replace: true });
} else {
// Session pas encore visible : rechargement complet comme auparavant
window.location.href = "/"; window.location.href = "/";
}, 500); }
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
toast({ toast({
@@ -138,16 +144,6 @@ export default function AuthPage() {
}, },
}); });
// Debug logging for authentication state
if (import.meta.env.DEV) {
console.log('🔍 AuthPage Debug:', {
isAuthenticated,
isLoading,
userId: user?.id,
username: user?.username
});
}
useEffect(() => { useEffect(() => {
if (defaultCredentialsCheck) { if (defaultCredentialsCheck) {
setShowDefaultCredentials(defaultCredentialsCheck.showDefault); setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
+255 -195
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo, useRef, useDeferredValue } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react"; import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -21,6 +21,7 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { useAuthUnified } from "@/hooks/useAuthUnified";
// Types // Types
interface Avoir { interface Avoir {
@@ -108,6 +109,58 @@ type AvoirUpdateData = AvoirFormData & {
nocodbVerifiedAt?: Date | null; nocodbVerifiedAt?: Date | null;
}; };
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
// effets et calculs mémoïsés à chaque rendu)
const NO_AVOIRS: Avoir[] = [];
// Nombre maximal de vérifications de facture simultanées
const MAX_CONCURRENT_VERIFICATIONS = 4;
// Le montant de l'avoir doit-il être remplacé par celui de la facture ?
// Oui s'il est vide ou différent au centime près ; sinon la mise à jour ne
// changerait rien et la requête est évitée.
function amountDiffers(current: unknown, invoiceAmount: number) {
if (current === null || current === undefined || current === '') return true;
return Math.round(Number(current) * 100) !== Math.round(Number(invoiceAmount) * 100);
}
// Filtrage par recherche (fournisseur, référence, commentaire)
function filterAvoirsBySearch(avoirsList: Avoir[], searchTerm: string) {
const searchLower = searchTerm.toLowerCase();
return avoirsList.filter((avoir) => (
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
avoir.comment?.toLowerCase().includes(searchLower)
));
}
// Durée d'envoi affichée dans le modal d'attente. Le compteur vit dans ce
// composant pour ne pas re-rendre toute la page chaque seconde ; il repart de
// zéro à chaque ouverture du modal et s'arrête à sa fermeture.
function UploadElapsedTime() {
const [processingSeconds, setProcessingSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setProcessingSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<p className="text-sm text-gray-600 text-center">
Envoi du fichier vers le webhook... {processingSeconds}s
</p>
{processingSeconds > 30 && (
<p className="text-xs text-gray-500 mt-2 text-center">
Le traitement peut prendre quelques minutes
</p>
)}
</>
);
}
export default function Avoirs() { export default function Avoirs() {
const [activeTab, setActiveTab] = useState("pending"); const [activeTab, setActiveTab] = useState("pending");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -122,10 +175,8 @@ export default function Avoirs() {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
// État pour le modal d'attente du webhook // État pour le modal d'attente du webhook (compteur dans UploadElapsedTime)
const [showWaitingModal, setShowWaitingModal] = useState(false); const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
// États pour le système de vérification de facture // États pour le système de vérification de facture
const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({}); const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({});
@@ -143,10 +194,8 @@ export default function Avoirs() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Fetch user profile // Utilisateur connecté : cache partagé par toute l'application (pas de requête supplémentaire)
const { data: user } = useQuery({ const { user } = useAuthUnified();
queryKey: ['/api/user'],
});
// Fetch groups for store filter // Fetch groups for store filter
const { data: groups = [] } = useQuery<Group[]>({ const { data: groups = [] } = useQuery<Group[]>({
@@ -162,86 +211,110 @@ export default function Avoirs() {
// Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries) // Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries)
const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: avoirs = [], isLoading } = useQuery<Avoir[]>({ const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<Avoir[]>({
queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role], queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role],
queryFn: async () => { queryFn: async () => {
console.log('💰 Fetching avoirs from:', avoirsUrl);
const response = await fetch(avoirsUrl, { credentials: 'include' }); const response = await fetch(avoirsUrl, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch avoirs'); throw new Error('Failed to fetch avoirs');
} }
const data = await response.json(); const data = await response.json();
console.log('💰 Avoirs received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
// 🔍 Debug: Vérifier les champs nocodbVerified
if (Array.isArray(data) && data.length > 0) {
console.log('🔍 Premiers avoirs avec nocodbVerified:', data.map(a => ({
id: a.id,
invoiceReference: a.invoiceReference,
nocodbVerified: a.nocodbVerified,
nocodbVerifiedAt: a.nocodbVerifiedAt
})));
}
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}, },
enabled: !!user, enabled: !!user,
}); });
// 🔄 Charger les vérifications depuis le cache serveur au démarrage // Recharge la liste des avoirs, quelle que soit la variante de clé
useEffect(() => { // (avec ou sans magasin sélectionné, bureau ou mobile)
const loadCachedVerifications = async () => { const invalidateAvoirs = () => queryClient.invalidateQueries({
console.log('🔍 LoadCache - Début vérification:', { predicate: (query) => {
hasAvoirs: !!avoirs, return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
avoirCount: avoirs?.length || 0 }
}); });
if (!avoirs || avoirs.length === 0) return; // Données du PUT qui reporte le montant de la facture sur l'avoir : le serveur
// remet à null les champs absents, on renvoie donc les valeurs actuelles
const buildAmountUpdate = (avoir: Avoir, amount: number): AvoirUpdateData => ({
supplierId: avoir.supplierId,
groupId: avoir.groupId,
invoiceReference: avoir.invoiceReference || "",
amount,
comment: avoir.comment || "",
commercialProcessed: avoir.commercialProcessed || false,
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
});
// ---------------------------------------------------------------------------
// File d'attente des vérifications de facture : au plus
// MAX_CONCURRENT_VERIFICATIONS requêtes simultanées, au lieu d'une boucle
// séquentielle au chargement ou d'une rafale de requêtes (« Vérifier toutes »).
// ---------------------------------------------------------------------------
const verificationQueueRef = useRef<Array<() => Promise<void>>>([]);
const activeVerificationsRef = useRef(0);
// Référence déjà vérifiée (ou en cours) pour chaque avoir : un rechargement de
// la liste ne relance pas les vérifications déjà faites
const verifiedReferencesRef = useRef<Map<number, string>>(new Map());
// Dernière liste reçue, relue au moment de remplir un montant
const avoirsRef = useRef<Avoir[]>(avoirs);
avoirsRef.current = avoirs;
const drainVerificationQueue = () => {
while (
activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS &&
verificationQueueRef.current.length > 0
) {
const task = verificationQueueRef.current.shift();
if (!task) break;
activeVerificationsRef.current += 1;
task().finally(() => {
activeVerificationsRef.current -= 1;
drainVerificationQueue();
});
}
};
// Met les tâches en file ; la promesse est résolue quand toutes sont terminées
// (chaque tâche traite elle-même ses erreurs)
const runVerificationTasks = (tasks: Array<() => Promise<void>>) =>
Promise.all(tasks.map(task => new Promise<void>(resolve => {
verificationQueueRef.current.push(() => task().catch(() => undefined).finally(resolve));
drainVerificationQueue();
})));
// 🔄 Charger les vérifications depuis le cache serveur à l'affichage de la liste.
// Seuls les avoirs non validés dont la référence n'a pas encore été vérifiée
// sont envoyés (un avoir validé affiche « Validé » quel que soit le résultat) ;
// les résultats sont appliqués en une seule fois, comme auparavant.
useEffect(() => {
const avoirsToVerify = avoirs.filter(avoir =>
avoir.invoiceReference?.trim() &&
!avoir.nocodbVerified &&
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
);
if (avoirsToVerify.length === 0) return;
// Marquage immédiat : un rechargement pendant la vérification ne la double pas
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
const cachedResults: Record<number, any> = {}; const cachedResults: Record<number, any> = {};
runVerificationTasks(avoirsToVerify.map(avoir => async () => {
// Charger les vérifications depuis le cache serveur pour TOUS les avoirs avec référence
for (const avoir of avoirs) {
console.log('🔍 LoadCache - Avoir analysé:', {
id: avoir.id,
hasInvoiceRef: !!avoir.invoiceReference?.trim(),
invoiceReference: avoir.invoiceReference,
nocodbVerified: avoir.nocodbVerified
});
// Si l'avoir a une référence de facture, vérifier le cache serveur
if (avoir.invoiceReference?.trim()) {
try { try {
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', { const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
invoiceReference: avoir.invoiceReference, invoiceReference: avoir.invoiceReference,
forceRefresh: false // Utiliser le cache si disponible forceRefresh: false // Utiliser le cache si disponible
}); });
if (result?.exists !== undefined) {
console.log('🔍 LoadCache - Résultat cache serveur pour avoir', avoir.id, ':', result);
if (result.exists !== undefined) {
cachedResults[avoir.id] = result; cachedResults[avoir.id] = result;
console.log('✅ LoadCache - Avoir ajouté au cache depuis serveur:', avoir.id);
} }
} catch (error) { } catch {
console.log('⚠️ LoadCache - Erreur cache serveur pour avoir', avoir.id, ':', error); // Vérification silencieuse : la loupe reste disponible pour réessayer
// Continuer sans erreur si le cache échoue
} }
} })).then(() => {
}
console.log('🔍 LoadCache - Résultats finaux:', {
cachedCount: Object.keys(cachedResults).length,
cachedResults
});
if (Object.keys(cachedResults).length > 0) { if (Object.keys(cachedResults).length > 0) {
setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults })); setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults }));
console.log('✅ Vérifications chargées depuis le serveur:', cachedResults);
} }
}; });
loadCachedVerifications();
}, [avoirs]); }, [avoirs]);
// Create avoir mutation // Create avoir mutation
@@ -262,11 +335,7 @@ export default function Avoirs() {
}, },
onSuccess: () => { onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs // ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({ invalidateAvoirs();
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
toast({ toast({
title: "Avoir créé", title: "Avoir créé",
@@ -300,11 +369,7 @@ export default function Avoirs() {
}, },
onSuccess: () => { onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs // ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({ invalidateAvoirs();
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
setSelectedAvoir(null); setSelectedAvoir(null);
toast({ toast({
@@ -335,11 +400,7 @@ export default function Avoirs() {
}, },
onSuccess: () => { onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs // ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({ invalidateAvoirs();
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setSelectedAvoir(null); setSelectedAvoir(null);
toast({ toast({
@@ -411,13 +472,6 @@ export default function Avoirs() {
amount: finalAmount amount: finalAmount
}; };
console.log('💰 Editing avoir - Form data sent:', {
originalCommercial: selectedAvoir.commercialProcessed,
formCommercial: finalData.commercialProcessed,
editAmountValue,
finalAmount,
fullData: finalData
});
editAvoirMutation.mutate({ id: selectedAvoir.id, data: finalData }); editAvoirMutation.mutate({ id: selectedAvoir.id, data: finalData });
} }
}; };
@@ -498,34 +552,22 @@ export default function Avoirs() {
// Handle validation/devalidation // Handle validation/devalidation
const handleValidateAvoir = async (avoirId: number) => { const handleValidateAvoir = async (avoirId: number) => {
try { try {
// Utiliser la route spécifique pour la vérification NocoDB // Utiliser la route spécifique pour la vérification NocoDB. Elle marque
// aussi le cache de vérification comme réconcilié (permanent) : un appel
// séparé à /api/cache/mark-reconciled serait redondant.
await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', { await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', {
verified: true verified: true
}); });
// Marquer le cache comme réconcilié (permanent)
const avoir = avoirs.find(a => a.id === avoirId);
if (avoir?.invoiceReference) {
try {
await apiRequest('/api/cache/mark-reconciled', 'POST', {
invoiceReference: avoir.invoiceReference,
groupId: avoir.groupId
});
} catch (cacheError) {
console.warn('Cache marking failed but verification succeeded:', cacheError);
}
}
// ✅ Mettre à jour l'état local immédiatement pour affichage instantané // ✅ Mettre à jour l'état local immédiatement pour affichage instantané
setAvoirVerificationResults(prev => ({ setAvoirVerificationResults(prev => ({
...prev, ...prev,
[avoirId]: { exists: true, fromCache: true, permanent: true, validated: true } [avoirId]: { exists: true, fromCache: true, permanent: true, validated: true }
})); }));
console.log('✅ Avoir validé - État local mis à jour:', avoirId); // Recharger les avoirs pour voir les changements en base (toutes les
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
// Recharger les avoirs pour voir les changements en base invalidateAvoirs();
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
toast({ toast({
title: "Avoir validé", title: "Avoir validé",
@@ -553,11 +595,12 @@ export default function Avoirs() {
delete updated[avoirId]; // Supprimer complètement l'entrée delete updated[avoirId]; // Supprimer complètement l'entrée
return updated; return updated;
}); });
// L'avoir sera revérifié au rechargement de la liste
verifiedReferencesRef.current.delete(avoirId);
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId); // Recharger les avoirs pour voir les changements en base (toutes les
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
// Recharger les avoirs pour voir les changements en base invalidateAvoirs();
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
toast({ toast({
title: "Avoir dévalidé", title: "Avoir dévalidé",
@@ -593,26 +636,14 @@ export default function Avoirs() {
})); }));
// Auto-remplissage sécurisé si facture trouvée et montant disponible // Auto-remplissage sécurisé si facture trouvée et montant disponible
// (seulement si le montant change : sinon la mise à jour serait inutile)
if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) { if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) {
const avoir = avoirs?.find(a => a?.id === variables.avoirId); const avoir = avoirs?.find(a => a?.id === variables.avoirId);
if (avoir && avoir.supplierId && avoir.groupId) { if (avoir && avoir.supplierId && avoir.groupId && amountDiffers(avoir.amount, result.invoiceAmount)) {
try {
console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount });
editAvoirMutation.mutate({ editAvoirMutation.mutate({
id: variables.avoirId, id: variables.avoirId,
data: { data: buildAmountUpdate(avoir, result.invoiceAmount),
supplierId: avoir.supplierId,
groupId: avoir.groupId,
invoiceReference: avoir.invoiceReference || "",
amount: result.invoiceAmount,
comment: avoir.comment || "",
commercialProcessed: avoir.commercialProcessed || false,
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
}
}); });
} catch (autoFillError) {
console.error('❌ Erreur auto-remplissage:', autoFillError);
}
} }
} }
@@ -669,13 +700,8 @@ export default function Avoirs() {
return; return;
} }
console.log('🔍 Déclenchement vérification avoir:', { // Référence vérifiée : le rechargement de la liste ne la revérifiera pas
avoirId: avoir.id, verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || '');
invoiceReference: avoir.invoiceReference,
supplier: avoir.supplier?.name,
group: avoir.group?.name
});
setVerifyingAvoirs(prev => new Set(prev).add(avoir.id)); setVerifyingAvoirs(prev => new Set(prev).add(avoir.id));
verifyAvoirInvoiceMutation.mutate({ verifyAvoirInvoiceMutation.mutate({
@@ -685,8 +711,11 @@ export default function Avoirs() {
}); });
}; };
// Fonction pour vérifier tous les avoirs avec une référence facture // Fonction pour vérifier tous les avoirs avec une référence facture.
const handleVerifyAllAvoirInvoices = () => { // File d'attente bornée, montants remplis sans recharger la liste à chaque
// avoir (ni toast, ni fermeture de modale), puis un seul rafraîchissement et
// un seul toast récapitulatif à la fin.
const handleVerifyAllAvoirInvoices = async () => {
const avoirsToVerify = avoirs.filter(avoir => const avoirsToVerify = avoirs.filter(avoir =>
avoir.invoiceReference?.trim() && avoir.invoiceReference?.trim() &&
(avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId) (avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId)
@@ -700,21 +729,84 @@ export default function Avoirs() {
return; return;
} }
avoirsToVerify.forEach((avoir, index) => {
// Délai échelonné pour éviter la surcharge
setTimeout(() => {
handleVerifyAvoirInvoice(avoir, true); // Force refresh pour tous
}, index * 200); // 200ms entre chaque vérification
});
toast({ toast({
title: "Vérification lancée", title: "Vérification lancée",
description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`, description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`,
}); });
};
// ✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript setVerifyingAvoirs(prev => {
// Les vérifications se font maintenant uniquement à la demande utilisateur const next = new Set(prev);
avoirsToVerify.forEach(avoir => next.add(avoir.id));
return next;
});
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
let found = 0;
let notFound = 0;
let failed = 0;
let amountsUpdated = 0;
await runVerificationTasks(avoirsToVerify.map(avoir => async () => {
let result: any;
let requestFailed = false;
try {
result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
invoiceReference: avoir.invoiceReference,
forceRefresh: true // Force refresh pour tous
});
} catch (error) {
console.error('Erreur vérification facture avoir:', error);
requestFailed = true;
result = {
exists: false,
matchType: 'none',
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
setAvoirVerificationResults(prev => ({ ...prev, [avoir.id]: result }));
setVerifyingAvoirs(prev => {
const next = new Set(prev);
next.delete(avoir.id);
return next;
});
if (requestFailed) {
failed += 1;
return;
}
if (result?.exists !== true) {
notFound += 1;
return;
}
found += 1;
// Report du montant de la facture, à partir des données les plus récentes de l'avoir
if (result.invoiceAmount === undefined || result.invoiceAmount === null) return;
const current = avoirsRef.current.find(a => a?.id === avoir.id) ?? avoir;
if (!current.supplierId || !current.groupId || !amountDiffers(current.amount, result.invoiceAmount)) return;
try {
await apiRequest(`/api/avoirs/${avoir.id}`, 'PUT', buildAmountUpdate(current, result.invoiceAmount));
amountsUpdated += 1;
} catch (error) {
console.error('❌ Erreur auto-remplissage:', error);
}
}));
// Un seul rechargement de la liste, seulement si des montants ont changé
if (amountsUpdated > 0) {
invalidateAvoirs();
}
const details = [`${found} facture(s) trouvée(s)`, `${notFound} introuvable(s)`];
if (failed > 0) details.push(`${failed} erreur(s)`);
if (amountsUpdated > 0) details.push(`${amountsUpdated} montant(s) mis à jour`);
toast({
title: "Vérification terminée",
description: details.join(', '),
variant: failed > 0 ? "destructive" : "default",
});
};
// 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement) // 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement)
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -743,22 +835,9 @@ export default function Avoirs() {
setIsUploading(false); setIsUploading(false);
}; };
const startProcessingTimer = () => {
setProcessingSeconds(0);
const interval = setInterval(() => {
setProcessingSeconds(prev => prev + 1);
}, 1000);
setProcessingTimeout(interval);
};
const handleCloseWaitingModal = () => { const handleCloseWaitingModal = () => {
setShowWaitingModal(false); setShowWaitingModal(false);
setIsUploading(false); setIsUploading(false);
setProcessingSeconds(0);
if (processingTimeout) {
clearInterval(processingTimeout);
setProcessingTimeout(null);
}
}; };
const handleSendAvoir = async () => { const handleSendAvoir = async () => {
@@ -789,7 +868,6 @@ export default function Avoirs() {
setShowAvoirModal(false); setShowAvoirModal(false);
setShowWaitingModal(true); setShowWaitingModal(true);
setIsUploading(true); setIsUploading(true);
startProcessingTimer();
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -833,11 +911,7 @@ export default function Avoirs() {
setSelectedFile(null); setSelectedFile(null);
// Invalider les caches pour recharger // Invalider les caches pour recharger
queryClient.invalidateQueries({ invalidateAvoirs();
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
} catch (error: any) { } catch (error: any) {
handleCloseWaitingModal(); handleCloseWaitingModal();
@@ -867,17 +941,6 @@ export default function Avoirs() {
} }
}; };
// Filter avoirs based on search term
const filteredAvoirs = avoirs.filter(avoir =>
avoir.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) ||
avoir.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
avoir.comment?.toLowerCase().includes(searchTerm.toLowerCase()) ||
(avoir.creator.firstName && avoir.creator.lastName
? `${avoir.creator.firstName} ${avoir.creator.lastName}`.toLowerCase().includes(searchTerm.toLowerCase())
: avoir.creator.username.toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Status badge variant // Status badge variant
const getStatusVariant = (status: string) => { const getStatusVariant = (status: string) => {
switch (status) { switch (status) {
@@ -906,24 +969,27 @@ export default function Avoirs() {
} }
}; };
// Séparer les avoirs par statut : Finalisé = Reçu ET validé // Séparer les avoirs par statut : Finalisé = Reçu ET validé (recalculé
const pendingAvoirs = avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified)); // seulement quand la liste change, pas à chaque frappe ou résultat de vérification)
const completedAvoirs = avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified); const pendingAvoirs = useMemo(
() => avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified)),
// Filtrage par recherche [avoirs]
const filterAvoirs = (avoirsList: Avoir[]) => { );
return avoirsList.filter((avoir) => { const completedAvoirs = useMemo(
const searchLower = searchTerm.toLowerCase(); () => avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified),
return ( [avoirs]
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
avoir.comment?.toLowerCase().includes(searchLower)
); );
});
};
const filteredPendingAvoirs = filterAvoirs(pendingAvoirs); // Filtrage par recherche, différée pour garder la saisie fluide
const filteredCompletedAvoirs = filterAvoirs(completedAvoirs); const deferredSearchTerm = useDeferredValue(searchTerm);
const filteredPendingAvoirs = useMemo(
() => filterAvoirsBySearch(pendingAvoirs, deferredSearchTerm),
[pendingAvoirs, deferredSearchTerm]
);
const filteredCompletedAvoirs = useMemo(
() => filterAvoirsBySearch(completedAvoirs, deferredSearchTerm),
[completedAvoirs, deferredSearchTerm]
);
const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role); const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role);
@@ -1243,7 +1309,8 @@ export default function Avoirs() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{renderAvoirTable(filteredPendingAvoirs)} {/* Seul le tableau de l'onglet affiché est construit */}
{activeTab === "pending" && renderAvoirTable(filteredPendingAvoirs)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -1260,7 +1327,7 @@ export default function Avoirs() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{renderAvoirTable(filteredCompletedAvoirs)} {activeTab === "completed" && renderAvoirTable(filteredCompletedAvoirs)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -1691,14 +1758,7 @@ export default function Avoirs() {
<div className="flex flex-col items-center p-6"> <div className="flex flex-col items-center p-6">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-sm text-gray-600 text-center"> <UploadElapsedTime />
Envoi du fichier vers le webhook... {processingSeconds}s
</p>
{processingSeconds > 30 && (
<p className="text-xs text-gray-500 mt-2 text-center">
Le traitement peut prendre quelques minutes
</p>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
-1
View File
@@ -1 +0,0 @@
// Backup temporaire pour restaurer si nécessaire
File diff suppressed because it is too large. Load diff
-778
View File
@@ -1,778 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { safeFormat } from "@/lib/dateUtils";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
import type { Supplier } from "@shared/schema";
export default function BLReconciliation() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Vérification des permissions d'accès au module
if (!permissions.canView('reconciliation')) {
return (
<div className="p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<FileText className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Seuls les directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
</p>
</div>
</div>
</div>
</div>
);
}
const [activeTab, setActiveTab] = useState("manual");
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'],
});
// Récupérer les livraisons validées avec BL
const { data: deliveriesWithBL = [], isLoading } = useQuery({
queryKey: ['/api/deliveries/bl', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams({});
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/deliveries?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch deliveries');
}
const deliveries = await response.json();
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
},
});
// Séparer les livraisons selon le mode de rapprochement du fournisseur
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return !supplier?.automaticReconciliation;
});
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return supplier?.automaticReconciliation;
});
// Mutations pour les actions
const validateManualMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: true,
validatedAt: new Date().toISOString()
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
variant: "destructive",
});
}
});
const devalidateMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: false,
validatedAt: null
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement dévalidé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de dévalider le rapprochement",
variant: "destructive",
});
}
});
const deleteMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Ligne supprimée avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer la ligne",
variant: "destructive",
});
}
});
// Fonctions de gestion des actions
const handleValidateManual = (deliveryId: number) => {
if (!permissions.canValidate('reconciliation')) {
toast({
title: "Accès refusé",
description: "Vous n'avez pas les permissions pour valider les rapprochements",
variant: "destructive",
});
return;
}
validateManualMutation.mutate(deliveryId);
};
const handleDevalidate = (deliveryId: number) => {
if (!permissions.canEdit('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
devalidateMutation.mutate(deliveryId);
}
};
const handleDelete = (deliveryId: number) => {
if (!permissions.canDelete('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent supprimer les lignes",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
deleteMutation.mutate(deliveryId);
}
};
// Filtrage des livraisons par recherche
const filterDeliveries = (deliveries: any[]) => {
return deliveries.filter((delivery: any) => {
const searchLower = searchTerm.toLowerCase();
return (
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
);
});
};
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
// Pagination
const getCurrentPageData = (data: any[]) => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return data.slice(startIndex, endIndex);
};
const getTotalPages = (data: any[]) => {
return Math.ceil(data.length / itemsPerPage);
};
const currentManualData = getCurrentPageData(filteredManualDeliveries);
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
const totalManualPages = getTotalPages(filteredManualDeliveries);
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
// Reset page when changing tabs or searching
useEffect(() => {
setCurrentPage(1);
}, [activeTab, searchTerm]);
// Vérifier si une ligne est dévalidée (pour la coloration)
const isDevalidated = (delivery: any) => {
return delivery.reconciled === false && delivery.validatedAt === null;
};
// Composant de pagination simplifiée
const NativePagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
itemsPerPage: number;
}) => {
if (totalPages <= 1) return null;
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
<div className="flex items-center text-sm text-gray-700">
<span>
Affichage de {startItem} à {endItem} sur {totalItems} éléments
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<ChevronLeft className="w-4 h-4" />
<span>Précédent</span>
</button>
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
Page {currentPage} sur {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<span>Suivant</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
// Badge native
const NativeBadge = ({ children, variant = "default", className = "" }: { children: React.ReactNode, variant?: string, className?: string }) => {
const baseClasses = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium";
const variantClasses = {
default: "bg-blue-100 text-blue-800",
secondary: "bg-gray-100 text-gray-800",
destructive: "bg-red-100 text-red-800",
outline: "border border-gray-300 text-gray-700 bg-white"
};
return (
<span className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${className}`}>
{children}
</span>
);
};
// Button native
const NativeButton = ({ children, onClick, disabled = false, variant = "default", size = "default", className = "", title = "" }: {
children: React.ReactNode,
onClick?: () => void,
disabled?: boolean,
variant?: string,
size?: string,
className?: string,
title?: string
}) => {
const baseClasses = "inline-flex items-center justify-center font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantClasses = {
default: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
};
const sizeClasses = {
default: "px-4 py-2 text-sm",
sm: "px-2 py-1 text-xs"
};
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${sizeClasses[size as keyof typeof sizeClasses] || sizeClasses.default} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{children}
</button>
);
};
if (isLoading || suppliersLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header avec onglets */}
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
<FileText className="w-6 h-6 mr-3 text-blue-600" />
Rapprochement BL/Factures
</h2>
<p className="text-gray-600 mt-1">
Gestion des rapprochements manuels et automatiques
</p>
</div>
<div className="flex items-center space-x-4">
<NativeBadge variant="outline" className="text-sm border border-gray-300">
{manualReconciliationDeliveries.length} rapprochements manuels
</NativeBadge>
<NativeBadge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
{automaticReconciliationDeliveries.length} rapprochements automatiques
</NativeBadge>
</div>
</div>
{/* Onglets natifs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("manual")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "manual"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Edit className="w-4 h-4" />
<span>Rapprochement Manuel</span>
<NativeBadge variant="secondary" className="ml-2">
{manualReconciliationDeliveries.length}
</NativeBadge>
</button>
<button
onClick={() => setActiveTab("automatic")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "automatic"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Settings className="w-4 h-4" />
<span>Rapprochement Automatique</span>
<NativeBadge variant="secondary" className="ml-2">
{automaticReconciliationDeliveries.length}
</NativeBadge>
</button>
</nav>
</div>
</div>
{/* Filtre de recherche */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher par fournisseur, BL ou facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Contenu des onglets */}
{activeTab === "manual" && (
<div className="space-y-6">
{filteredManualDeliveries.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement manuel trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fournisseur</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant Fact.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentManualData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
delivery.reconciled ? 'bg-green-50' :
isDevalidated(delivery) ? 'bg-red-50' : ''
}`}
>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.blNumber || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceAmount ?
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
{delivery.reconciled ? (
<NativeBadge variant="default" className="bg-green-600 text-white">
Validé
</NativeBadge>
) : isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="outline">
En attente
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
)}
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
)}
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
{activeTab === "automatic" && (
<div className="space-y-6">
{/* Message d'information */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
<p className="text-sm text-blue-700 mt-1">
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
{permissions.canEdit('reconciliation') ? (
" Vous pouvez dévalider ces rapprochements si nécessaire."
) : (
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
)}
</p>
</div>
</div>
</div>
{filteredAutomaticDeliveries.length === 0 ? (
<div className="text-center py-12">
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement automatique trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
<NativeBadge variant="secondary" className="ml-2 text-xs">AUTO</NativeBadge>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Valid.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentAutomaticData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
}`}
>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900 font-medium">
{delivery.blNumber || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.reconciled && delivery.validatedAt ?
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
{isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="default" className="bg-green-600 text-white">
Auto-validé
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<>
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
</>
)}
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
</div>
);
}
+9 -1
View File
@@ -54,11 +54,19 @@ export default function BackupManager() {
const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage'); const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage');
// Fetch backup list // Fetch backup list
// Rafraîchissement automatique, suspendu quand l'onglet du navigateur est en
// arrière-plan (comportement par défaut de React Query) : toutes les 30 s tant
// qu'une sauvegarde est en cours, sinon toutes les 5 min (la liste ne change
// qu'à la sauvegarde automatique ou après une action, déjà suivie d'un rechargement)
const { data: backups = [], isLoading, error } = useQuery({ const { data: backups = [], isLoading, error } = useQuery({
queryKey: ['/api/backups'], queryKey: ['/api/backups'],
queryFn: () => apiRequest('/api/backups'), queryFn: () => apiRequest('/api/backups'),
enabled: canManageBackups, enabled: canManageBackups,
refetchInterval: 30000, // Refresh every 30 seconds refetchInterval: (query) => {
const list = query.state.data;
const hasRunningBackup = Array.isArray(list) && list.some((backup: BackupFile) => backup.status === 'creating');
return hasRunningBackup ? 30 * 1000 : 5 * 60 * 1000;
},
}); });
// Fetch utilities configuration // Fetch utilities configuration
+47 -74
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useCallback, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import CalendarGrid from "@/components/CalendarGrid"; import CalendarGrid from "@/components/CalendarGrid";
@@ -16,6 +16,9 @@ import { format, startOfMonth, endOfMonth } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
// Référence stable tant que les données ne sont pas chargées (CalendarGrid est mémorisé)
const NO_ITEMS: any[] = [];
export default function Calendar() { export default function Calendar() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -30,36 +33,20 @@ export default function Calendar() {
const [selectedItem, setSelectedItem] = useState<any>(null); const [selectedItem, setSelectedItem] = useState<any>(null);
const monthStart = startOfMonth(currentDate); const startDate = format(startOfMonth(currentDate), 'yyyy-MM-dd');
const monthEnd = endOfMonth(currentDate); const endDate = format(endOfMonth(currentDate), 'yyyy-MM-dd');
// Fetch orders and deliveries for the current month with store filtering // Fetch orders and deliveries for the current month with store filtering
const { data: orders = [], isLoading: loadingOrders } = useQuery({ const { data: orders = NO_ITEMS, isLoading: loadingOrders, isPlaceholderData: ordersPlaceholder } = useQuery({
queryKey: ['/api/orders', selectedStoreId, { queryKey: ['/api/orders', selectedStoreId, { startDate, endDate }],
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
}],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({ startDate, endDate });
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
});
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin // CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
if (selectedStoreId) { if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString()); params.append('storeId', selectedStoreId.toString());
} }
const url = `/api/orders?${params.toString()}`; const response = await fetch(`/api/orders?${params.toString()}`, {
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching orders:', {
url,
selectedStoreId,
userRole: user?.role,
params: params.toString()
});
}
const response = await fetch(url, {
credentials: 'include' credentials: 'include'
}); });
@@ -68,9 +55,6 @@ export default function Calendar() {
} }
const data = await response.json(); const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
// Protection contre les données invalides en production // Protection contre les données invalides en production
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
@@ -81,34 +65,22 @@ export default function Calendar() {
}, },
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
gcTime: 10 * 60 * 1000, // 10 minutes en cache gcTime: 10 * 60 * 1000, // 10 minutes en cache
// Changement de mois : garder la grille affichée pendant le chargement au lieu
// du spinner. Jamais au changement de magasin (données d'un autre magasin).
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({ const { data: deliveries = NO_ITEMS, isLoading: loadingDeliveries, isPlaceholderData: deliveriesPlaceholder } = useQuery({
queryKey: ['/api/deliveries', selectedStoreId, { queryKey: ['/api/deliveries', selectedStoreId, { startDate, endDate }],
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
}],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({ startDate, endDate });
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
});
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin // CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
if (selectedStoreId) { if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString()); params.append('storeId', selectedStoreId.toString());
} }
const url = `/api/deliveries?${params.toString()}`; const response = await fetch(`/api/deliveries?${params.toString()}`, {
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching deliveries:', {
url,
selectedStoreId,
userRole: user?.role,
params: params.toString()
});
}
const response = await fetch(url, {
credentials: 'include' credentials: 'include'
}); });
@@ -117,9 +89,6 @@ export default function Calendar() {
} }
const data = await response.json(); const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
// Protection contre les données invalides en production // Protection contre les données invalides en production
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
@@ -130,10 +99,13 @@ export default function Calendar() {
}, },
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
gcTime: 10 * 60 * 1000, // 10 minutes en cache gcTime: 10 * 60 * 1000, // 10 minutes en cache
// Même règle que pour les commandes : jamais au changement de magasin
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
// Fetch publicities for the current year // Fetch publicities for the current year
const { data: publicities = [], isLoading: loadingPublicities } = useQuery({ const { data: publicities = NO_ITEMS } = useQuery({
queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId], queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -142,17 +114,7 @@ export default function Calendar() {
params.append('storeId', selectedStoreId.toString()); params.append('storeId', selectedStoreId.toString());
} }
const url = `/api/ad-campaigns?${params.toString()}`; const response = await fetch(`/api/ad-campaigns?${params.toString()}`, {
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching publicities:', {
url,
selectedStoreId,
userRole: user?.role,
year: currentDate.getFullYear()
});
}
const response = await fetch(url, {
credentials: 'include' credentials: 'include'
}); });
@@ -161,11 +123,12 @@ export default function Calendar() {
} }
const data = await response.json(); const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar publicities received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}, },
// Changement d'année : garder les publicités affichées pendant le chargement,
// sauf au changement de magasin
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[2] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
const navigateMonth = (direction: 'prev' | 'next') => { const navigateMonth = (direction: 'prev' | 'next') => {
@@ -178,16 +141,18 @@ export default function Calendar() {
setCurrentDate(newDate); setCurrentDate(newDate);
}; };
const handleDateClick = (date: Date) => { // Gestionnaires stables : CalendarGrid (mémorisé) n'est pas re-rendu à
// l'ouverture ou à la fermeture des modales
const handleDateClick = useCallback((date: Date) => {
setSelectedDate(date); setSelectedDate(date);
setShowQuickCreate(true); setShowQuickCreate(true);
}; }, []);
const handleItemClick = (item: any, type: 'order' | 'delivery') => { const handleItemClick = useCallback((item: any, type: 'order' | 'delivery') => {
// Ne pas invalider le cache à l'ouverture pour éviter la disparition des données // Ne pas invalider le cache à l'ouverture pour éviter la disparition des données
setSelectedItem({ ...item, type }); setSelectedItem({ ...item, type });
setShowOrderDetail(true); setShowOrderDetail(true);
}; }, []);
const handleCreateOrder = () => { const handleCreateOrder = () => {
setShowQuickCreate(false); setShowQuickCreate(false);
@@ -201,7 +166,10 @@ export default function Calendar() {
const isLoading = loadingOrders || loadingDeliveries || loadingPublicities; // La grille n'attend pas les publicités : elles s'affichent dès leur arrivée
const isLoading = loadingOrders || loadingDeliveries;
// Mois précédent encore affiché pendant le chargement du nouveau mois
const isMonthLoading = ordersPlaceholder || deliveriesPlaceholder;
return ( return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
@@ -284,16 +252,21 @@ export default function Calendar() {
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div> </div>
) : ( ) : (
<div
className={`transition-opacity ${isMonthLoading ? 'opacity-60' : ''}`}
aria-busy={isMonthLoading}
>
<CalendarGrid <CalendarGrid
currentDate={currentDate} currentDate={currentDate}
orders={orders || []} orders={orders}
deliveries={deliveries || []} deliveries={deliveries}
publicities={publicities || []} publicities={publicities}
selectedStoreId={selectedStoreId} selectedStoreId={selectedStoreId}
userGroups={user?.userGroups || []} userGroups={user?.userGroups || NO_ITEMS}
onDateClick={handleDateClick} onDateClick={handleDateClick}
onItemClick={handleItemClick} onItemClick={handleItemClick}
/> />
</div>
)} )}
</div> </div>
+113 -11
View File
@@ -24,6 +24,7 @@ import {
import type { Supplier, Contact, Group } from "@shared/schema"; import type { Supplier, Contact, Group } from "@shared/schema";
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"]; const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
const CAN_EDIT_SUPPLIER = ["admin", "directeur"];
export default function Contacts() { export default function Contacts() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
@@ -32,6 +33,7 @@ export default function Contacts() {
const { stores, selectedStoreId } = useStore(); const { stores, selectedStoreId } = useStore();
const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role); const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role);
const canEditSupplier = user?.role && CAN_EDIT_SUPPLIER.includes(user.role);
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
// Pour les admins, permettre de choisir un magasin // Pour les admins, permettre de choisir un magasin
@@ -41,33 +43,40 @@ export default function Contacts() {
const [supplierSearch, setSupplierSearch] = useState(""); const [supplierSearch, setSupplierSearch] = useState("");
const [contactSearch, setContactSearch] = useState(""); const [contactSearch, setContactSearch] = useState("");
// Modal contacts libres
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedContact, setSelectedContact] = useState<Contact | null>(null); const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
groupId: effectiveGroupId ?? 0, groupId: effectiveGroupId ?? 0,
name: "", name: "",
company: "",
role: "", role: "",
phone: "", phone: "",
email: "", email: "",
notes: "", notes: "",
}); });
// Modal coordonnées fournisseur
const [showSupplierModal, setShowSupplierModal] = useState(false);
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierFormData, setSupplierFormData] = useState({
contact: "",
phone: "",
email: "",
});
const { data: suppliers = [] } = useQuery<Supplier[]>({ const { data: suppliers = [] } = useQuery<Supplier[]>({
queryKey: ["/api/suppliers"], queryKey: ["/api/suppliers"],
}); });
const contactsQueryKey = effectiveGroupId // Clé structurée : l'invalidation par préfixe ["/api/contacts"] des mutations
? [`/api/contacts?groupId=${effectiveGroupId}`] // ci-dessous rafraîchit aussi la liste filtrée par magasin
: ["/api/contacts"];
const { data: contacts = [] } = useQuery<Contact[]>({ const { data: contacts = [] } = useQuery<Contact[]>({
queryKey: contactsQueryKey, queryKey: ["/api/contacts", effectiveGroupId ?? null],
queryFn: async () => { queryFn: () =>
const url = effectiveGroupId apiRequest(effectiveGroupId ? `/api/contacts?groupId=${effectiveGroupId}` : "/api/contacts", "GET"),
? `/api/contacts?groupId=${effectiveGroupId}` // Sans magasin, un non-admin ne voit pas la liste : pas de requête
: "/api/contacts"; enabled: !!user && (isAdmin || !!effectiveGroupId),
return await apiRequest(url, "GET");
},
}); });
const createMutation = useMutation({ const createMutation = useMutation({
@@ -105,11 +114,25 @@ export default function Contacts() {
}, },
}); });
const updateSupplierMutation = useMutation({
mutationFn: (data: any) => apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data),
onSuccess: () => {
toast({ title: "Succès", description: "Coordonnées fournisseur mises à jour" });
queryClient.invalidateQueries({ queryKey: ["/api/suppliers"] });
setShowSupplierModal(false);
setSelectedSupplier(null);
},
onError: () => {
toast({ title: "Erreur", description: "Impossible de modifier le fournisseur", variant: "destructive" });
},
});
const openCreate = () => { const openCreate = () => {
setSelectedContact(null); setSelectedContact(null);
setFormData({ setFormData({
groupId: effectiveGroupId ?? 0, groupId: effectiveGroupId ?? 0,
name: "", name: "",
company: "",
role: "", role: "",
phone: "", phone: "",
email: "", email: "",
@@ -123,6 +146,7 @@ export default function Contacts() {
setFormData({ setFormData({
groupId: contact.groupId, groupId: contact.groupId,
name: contact.name, name: contact.name,
company: (contact as any).company || "",
role: contact.role || "", role: contact.role || "",
phone: contact.phone || "", phone: contact.phone || "",
email: contact.email || "", email: contact.email || "",
@@ -152,6 +176,16 @@ export default function Contacts() {
} }
}; };
const openEditSupplier = (supplier: Supplier) => {
setSelectedSupplier(supplier);
setSupplierFormData({
contact: supplier.contact || "",
phone: supplier.phone || "",
email: (supplier as any).email || "",
});
setShowSupplierModal(true);
};
const filteredSuppliers = suppliers.filter((s) => const filteredSuppliers = suppliers.filter((s) =>
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) || s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase()) (s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
@@ -260,6 +294,11 @@ export default function Contacts() {
)} )}
</div> </div>
</div> </div>
{canEditSupplier && (
<Button variant="ghost" size="sm" onClick={() => openEditSupplier(supplier)}>
<Edit className="w-4 h-4" />
</Button>
)}
</div> </div>
<div className="mt-3 space-y-1 pl-12"> <div className="mt-3 space-y-1 pl-12">
{supplier.phone && ( {supplier.phone && (
@@ -344,6 +383,9 @@ export default function Contacts() {
</div> </div>
<div> <div>
<p className="font-semibold text-gray-900">{contact.name}</p> <p className="font-semibold text-gray-900">{contact.name}</p>
{(contact as any).company && (
<p className="text-sm font-medium text-gray-700">{(contact as any).company}</p>
)}
{contact.role && ( {contact.role && (
<p className="text-sm text-gray-500">{contact.role}</p> <p className="text-sm text-gray-500">{contact.role}</p>
)} )}
@@ -445,6 +487,16 @@ export default function Contacts() {
/> />
</div> </div>
<div>
<Label htmlFor="c-company">Entreprise</Label>
<Input
id="c-company"
value={formData.company}
onChange={(e) => setFormData((p) => ({ ...p, company: e.target.value }))}
placeholder="Nom de l'entreprise"
/>
</div>
<div> <div>
<Label htmlFor="c-role">Fonction / Rôle</Label> <Label htmlFor="c-role">Fonction / Rôle</Label>
<Input <Input
@@ -505,6 +557,56 @@ export default function Contacts() {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal coordonnées fournisseur */}
<Dialog open={showSupplierModal} onOpenChange={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Coordonnées — {selectedSupplier?.name}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => { e.preventDefault(); updateSupplierMutation.mutate(supplierFormData); }}
className="space-y-4"
>
<div>
<Label htmlFor="s-contact">Nom du contact</Label>
<Input
id="s-contact"
value={supplierFormData.contact}
onChange={(e) => setSupplierFormData((p) => ({ ...p, contact: e.target.value }))}
placeholder="Nom du contact chez le fournisseur"
/>
</div>
<div>
<Label htmlFor="s-phone">Téléphone</Label>
<Input
id="s-phone"
value={supplierFormData.phone}
onChange={(e) => setSupplierFormData((p) => ({ ...p, phone: e.target.value }))}
placeholder="Numéro de téléphone"
/>
</div>
<div>
<Label htmlFor="s-email">Email</Label>
<Input
id="s-email"
type="email"
value={supplierFormData.email}
onChange={(e) => setSupplierFormData((p) => ({ ...p, email: e.target.value }))}
placeholder="Adresse email"
/>
</div>
<div className="flex items-center space-x-3 pt-2">
<Button type="button" variant="outline" onClick={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
Annuler
</Button>
<Button type="submit" disabled={updateSupplierMutation.isPending} className="bg-primary hover:bg-blue-700">
{updateSupplierMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+104 -83
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useMemo, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -30,13 +30,12 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Pagination, usePagination } from "@/components/ui/pagination"; import { Pagination, usePagination } from "@/components/ui/pagination";
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react"; import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react";
import JsBarcode from 'jsbarcode';
import { safeFormat, safeDate } from "@/lib/dateUtils"; import { safeFormat, safeDate } from "@/lib/dateUtils";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import type { CustomerOrderWithRelations, Group } from "@shared/schema"; import type { CustomerOrderWithRelations } from "@shared/schema";
import { CustomerOrderForm } from "@/components/CustomerOrderForm"; import { CustomerOrderForm } from "@/components/CustomerOrderForm";
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails"; import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
import { ConfirmationModal } from "@/components/ConfirmationModal"; import { ConfirmationModal } from "@/components/ConfirmationModal";
@@ -44,6 +43,10 @@ import ClientCallsModal from "@/components/modals/ClientCallsModal";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { usePermissions } from "@shared/permissions"; import { usePermissions } from "@shared/permissions";
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
// filtre et tri à chaque rendu)
const NO_ORDERS: CustomerOrderWithRelations[] = [];
export default function CustomerOrders() { export default function CustomerOrders() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -65,13 +68,11 @@ export default function CustomerOrders() {
const [filterSupplier, setFilterSupplier] = useState<string>("all"); const [filterSupplier, setFilterSupplier] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all"); const [filterStatus, setFilterStatus] = useState<string>("all");
// Fetch groups for store filter
const { data: groups = [] } = useQuery<Group[]>({
queryKey: ['/api/groups'],
});
// Query pour les appels clients en attente // Query pour les appels clients en attente
const { data: pendingCalls = [], isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({ // Le serveur refuse cette liste aux employés (403) : la requête et son
// rafraîchissement toutes les 30 s ne sont lancés que pour les autres rôles.
const canViewPendingCalls = !!user && user.role !== 'employee';
const { data: pendingCalls = NO_ORDERS, isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({
queryKey: ['/api/customer-orders/pending-calls', selectedStoreId], queryKey: ['/api/customer-orders/pending-calls', selectedStoreId],
queryFn: async () => { queryFn: async () => {
const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
@@ -79,11 +80,11 @@ export default function CustomerOrders() {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch pending calls'); throw new Error('Failed to fetch pending calls');
} }
const data = await response.json(); return response.json();
console.log('📞 Customer Orders - Pending calls received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
return data;
}, },
enabled: canViewPendingCalls,
refetchInterval: 30000, // Refetch every 30 seconds refetchInterval: 30000, // Refetch every 30 seconds
refetchIntervalInBackground: false, // pas de rafraîchissement onglet masqué
}); });
// Fetch suppliers for filter // Fetch suppliers for filter
@@ -93,21 +94,20 @@ export default function CustomerOrders() {
// Fetch customer orders with store filtering for admins // Fetch customer orders with store filtering for admins
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`; const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const { data: customerOrders = [], isLoading } = useQuery<CustomerOrderWithRelations[]>({ const { data: customerOrders = NO_ORDERS, isLoading, isPlaceholderData } = useQuery<CustomerOrderWithRelations[]>({
queryKey: [customerOrdersUrl, selectedStoreId], queryKey: [customerOrdersUrl, selectedStoreId],
// Au changement de magasin, la liste précédente reste affichée (estompée,
// non cliquable, avec un indicateur) au lieu de l'écran « Chargement... »
placeholderData: keepPreviousData,
}); });
// Create mutation // Create mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
console.log("🔥 CREATE MUTATION STARTED with data:", data); onSuccess: () => {
return apiRequest('/api/customer-orders', 'POST', data); // invalidateQueries recharge les listes affichées et marque les autres
}, // comme périmées (pas de second rechargement forcé)
onSuccess: (result) => {
console.log("✅ CREATE MUTATION SUCCESS:", result);
// Force refresh of the query with store context
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false }); queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
queryClient.refetchQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
setShowCreateModal(false); setShowCreateModal(false);
toast({ toast({
title: "Succès", title: "Succès",
@@ -243,15 +243,7 @@ export default function CustomerOrders() {
}; };
const handleCreateOrder = (data: any) => { const handleCreateOrder = (data: any) => {
console.log("🎯 HANDLE CREATE ORDER CALLED with data:", data);
console.log("🎯 createMutation state:", {
isIdle: createMutation.isIdle,
isPending: createMutation.isPending,
isError: createMutation.isError,
isSuccess: createMutation.isSuccess
});
createMutation.mutate(data); createMutation.mutate(data);
console.log("🎯 createMutation.mutate CALLED");
}; };
const handleEditOrder = (data: any) => { const handleEditOrder = (data: any) => {
@@ -298,7 +290,8 @@ export default function CustomerOrders() {
}; };
// Fonction pour générer un code-barres scannable // Fonction pour générer un code-barres scannable
const generateEAN13Barcode = (code: string): string => { // (JsBarcode est chargé à la demande par handlePrintLabel)
const generateEAN13Barcode = (JsBarcode: typeof import('jsbarcode'), code: string): string => {
try { try {
// Créer un canvas temporaire // Créer un canvas temporaire
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -318,8 +311,6 @@ export default function CustomerOrders() {
const checksum = calculateEAN13Checksum(processedCode); const checksum = calculateEAN13Checksum(processedCode);
const ean13 = processedCode + checksum; const ean13 = processedCode + checksum;
console.log('Code original:', code, 'EAN13 généré:', ean13);
// Générer le code-barres avec jsbarcode // Générer le code-barres avec jsbarcode
JsBarcode(canvas, ean13, { JsBarcode(canvas, ean13, {
format: "EAN13", format: "EAN13",
@@ -378,11 +369,32 @@ export default function CustomerOrders() {
} }
}; };
const handlePrintLabel = (order: CustomerOrderWithRelations) => { const handlePrintLabel = async (order: CustomerOrderWithRelations) => {
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette // Ouvrir une nouvelle fenêtre pour imprimer l'étiquette (immédiatement, dans
// le clic, pour ne pas être bloquée par le navigateur)
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
if (printWindow) { if (printWindow) {
const barcodeDisplay = order.gencode ? generateEAN13Barcode(order.gencode) : ''; let barcodeDisplay = '';
if (order.gencode) {
// JsBarcode n'est téléchargé qu'au moment d'imprimer une étiquette
let JsBarcode: typeof import('jsbarcode');
try {
({ default: JsBarcode } = await import('jsbarcode'));
} catch (error) {
// Fichier introuvable (nouvelle version en ligne) ou réseau coupé :
// pas d'étiquette avec un faux code-barres
console.error('Chargement du générateur de code-barres impossible:', error);
printWindow.close();
toast({
title: "Erreur",
description: "Impossible de préparer l'étiquette, réessayez",
variant: "destructive",
});
return;
}
barcodeDisplay = generateEAN13Barcode(JsBarcode, order.gencode);
}
if (printWindow.closed) return;
const isImageBarcode = barcodeDisplay.startsWith('data:'); const isImageBarcode = barcodeDisplay.startsWith('data:');
printWindow.document.write(` printWindow.document.write(`
@@ -653,16 +665,23 @@ export default function CustomerOrders() {
} }
}; };
// Filtre et tri recalculés seulement quand la liste ou un critère change
// (et non à chaque rendu, par exemple à chaque frappe dans le commentaire d'appel)
const sortedOrders = useMemo(() => {
if (!Array.isArray(customerOrders)) return NO_ORDERS;
const search = searchTerm.toLowerCase();
// Filter orders based on search term, supplier, and status // Filter orders based on search term, supplier, and status
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => { const filteredOrders = customerOrders.filter(order => {
// Search term filter // Search term filter
const matchesSearch = !searchTerm || const matchesSearch = !searchTerm ||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) || order.customerName.toLowerCase().includes(search) ||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) || order.productDesignation.toLowerCase().includes(search) ||
order.customerPhone.includes(searchTerm) || order.customerPhone.includes(searchTerm) ||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) || (order.productReference && order.productReference.toLowerCase().includes(search)) ||
(order.gencode && order.gencode.toLowerCase().includes(searchTerm.toLowerCase())) || (order.gencode && order.gencode.toLowerCase().includes(search)) ||
(order.supplier && order.supplier.name.toLowerCase().includes(searchTerm.toLowerCase())); (order.supplier && order.supplier.name.toLowerCase().includes(search));
// Supplier filter // Supplier filter
const matchesSupplier = filterSupplier === "all" || const matchesSupplier = filterSupplier === "all" ||
@@ -672,55 +691,41 @@ export default function CustomerOrders() {
const matchesStatus = filterStatus === "all" || order.status === filterStatus; const matchesStatus = filterStatus === "all" || order.status === filterStatus;
return matchesSearch && matchesSupplier && matchesStatus; return matchesSearch && matchesSupplier && matchesStatus;
}) : []; });
// Sort orders - Active orders first, then "Retiré" orders last
const sortedOrders = (() => {
// Séparer les commandes en deux groupes
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
// Fonction de tri commune
const sortFunction = (a: any, b: any) => {
let aValue: string | number;
let bValue: string | number;
// Valeur de tri calculée une seule fois par commande (et non à chaque comparaison)
const getSortValue = (order: CustomerOrderWithRelations): string | number => {
switch (sortBy) { switch (sortBy) {
case "date":
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
aValue = dateA ? dateA.getTime() : 0;
bValue = dateB ? dateB.getTime() : 0;
break;
case "status": case "status":
aValue = a.status; return order.status;
bValue = b.status;
break;
case "supplier": case "supplier":
aValue = a.supplier?.name || ""; return order.supplier?.name || "";
bValue = b.supplier?.name || ""; case "date":
break; default: {
default: const date = safeDate(order.createdAt);
const defaultDateA = safeDate(a.createdAt); return date ? date.getTime() : 0;
const defaultDateB = safeDate(b.createdAt);
aValue = defaultDateA ? defaultDateA.getTime() : 0;
bValue = defaultDateB ? defaultDateB.getTime() : 0;
} }
if (sortOrder === "desc") {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
} else {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} }
}; };
// Trier chaque groupe séparément // Fonction de tri commune
const sortedActiveOrders = [...activeOrders].sort(sortFunction); const sortGroup = (orders: CustomerOrderWithRelations[]) =>
const sortedRetiredOrders = [...retiredOrders].sort(sortFunction); orders
.map(order => ({ order, value: getSortValue(order) }))
.sort((a, b) => {
if (sortOrder === "desc") {
return a.value > b.value ? -1 : a.value < b.value ? 1 : 0;
} else {
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
}
})
.map(entry => entry.order);
// Concaténer : commandes actives d'abord, puis retiré en dernier // Sort orders - Active orders first, then "Retiré" orders last
return [...sortedActiveOrders, ...sortedRetiredOrders]; const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
})(); const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
return [...sortGroup(activeOrders), ...sortGroup(retiredOrders)];
}, [customerOrders, searchTerm, filterSupplier, filterStatus, sortBy, sortOrder]);
// Pagination // Pagination
const { const {
@@ -733,6 +738,12 @@ export default function CustomerOrders() {
totalItems totalItems
} = usePagination(sortedOrders, 10); } = usePagination(sortedOrders, 10);
// Retour à la première page au changement de magasin (la liste précédente
// reste affichée pendant le chargement, sa longueur ne change donc pas)
useEffect(() => {
setCurrentPage(1);
}, [selectedStoreId, setCurrentPage]);
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Alerte pour les appels clients en attente */} {/* Alerte pour les appels clients en attente */}
@@ -823,9 +834,19 @@ export default function CustomerOrders() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg"> <CardTitle className="text-lg">
Commandes ({totalItems}) Commandes ({totalItems})
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> {/* Estompé et non cliquable tant que la liste du nouveau magasin n'est pas arrivée */}
<CardContent
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
aria-busy={isPlaceholderData}
>
{isLoading ? ( {isLoading ? (
<div>Chargement...</div> <div>Chargement...</div>
) : ( ) : (
+109 -161
View File
@@ -1,54 +1,47 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react"; import { Calendar, Package, Clock, User, AlertTriangle, Truck, BarChart3, Megaphone, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react";
import { safeFormat, safeDate } from "@/lib/dateUtils"; import { safeFormat, safeDate } from "@/lib/dateUtils";
import type { PublicityWithRelations, DashboardMessage } from "@shared/schema"; import type { PublicityWithRelations } from "@shared/schema";
import AnnouncementCard from "@/components/AnnouncementCard"; import AnnouncementCard, { getAnnouncementsQueryOptions, type AnnouncementWithRelations } from "@/components/AnnouncementCard";
import { DlcAlertModal } from "@/components/DlcAlertModal"; import { DlcAlertModal } from "@/components/DlcAlertModal";
// Annonce la plus récente créée il y a moins de 2 jours (null s'il n'y en a pas)
const selectRecentAnnouncement = (announcements: AnnouncementWithRelations[]): AnnouncementWithRelations | null => {
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const recentAnnouncements = announcements.filter(announcement => {
if (!announcement.createdAt) return false;
const createdAt = safeDate(announcement.createdAt);
return createdAt && createdAt >= twoDaysAgo;
});
// filter() renvoie une copie : le tri ne modifie pas le cache partagé
return recentAnnouncements.length > 0
? recentAnnouncements.sort((a, b) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
})[0]
: null;
};
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
const queryClient = useQueryClient();
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false); const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
const [showDlcAlertModal, setShowDlcAlertModal] = useState(false); const [showDlcAlertModal, setShowDlcAlertModal] = useState(false);
// Invalider les queries DLC quand le magasin change // Toutes les requêtes attendent l'utilisateur (enabled: !!user) : le storeId envoyé
useEffect(() => { // dépend de son rôle, et une réponse obtenue sans lui resterait en cache sous la
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] }); // clé du magasin sélectionné
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
}, [selectedStoreId, queryClient]);
const { data: stats } = useQuery({
queryKey: ['/api/stats/monthly', selectedStoreId],
queryFn: async () => {
const currentDate = new Date();
const params = new URLSearchParams({
year: currentDate.getFullYear().toString(),
month: (currentDate.getMonth() + 1).toString(),
});
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/stats/monthly?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
return response.json();
},
});
// Statistiques annuelles pour la carte de délai moyen // Statistiques annuelles pour la carte de délai moyen
const { data: yearlyStats } = useQuery({ const { data: yearlyStats } = useQuery({
@@ -73,65 +66,29 @@ export default function Dashboard() {
return response.json(); return response.json();
}, },
enabled: !!user,
}); });
// Construire les URLs pour récupérer toutes les données (pas de filtrage par date) // Construire les URLs pour récupérer toutes les données (pas de filtrage par date)
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`; const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`; const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
// Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence // Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence
const { data: allOrders = [] } = useQuery({ const { data: allOrders = [] } = useQuery({
queryKey: [ordersUrl, selectedStoreId], queryKey: [ordersUrl, selectedStoreId],
enabled: !!user,
}); });
const { data: allDeliveries = [] } = useQuery({ const { data: allDeliveries = [] } = useQuery({
queryKey: [deliveriesUrl, selectedStoreId], queryKey: [deliveriesUrl, selectedStoreId],
enabled: !!user,
}); });
const { data: customerOrders = [] } = useQuery({ // Dernière annonce récente (créée il y a moins de 2 jours), extraite de la
queryKey: [customerOrdersUrl, selectedStoreId], // requête des annonces partagée avec AnnouncementCard
}); const { data: recentAnnouncement } = useQuery({
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
// Récupérer la dernière annonce récente (créée il y a moins de 2 jours) select: selectRecentAnnouncement,
const { data: recentAnnouncement } = useQuery<DashboardMessage | null>({
queryKey: ['/api/announcements/recent', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
params.append('recent', 'true');
const response = await fetch(`/api/announcements?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
return null;
}
const announcements: DashboardMessage[] = await response.json();
// Filtrer les annonces créées il y a moins de 2 jours côté client
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const recentAnnouncements = announcements.filter(announcement => {
if (!announcement.createdAt) return false;
const createdAt = safeDate(announcement.createdAt);
return createdAt && createdAt >= twoDaysAgo;
});
// Retourner la plus récente
return recentAnnouncements.length > 0
? recentAnnouncements.sort((a, b) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
})[0]
: null;
},
enabled: !!user, enabled: !!user,
}); });
@@ -193,43 +150,48 @@ export default function Dashboard() {
} }
}; };
// Récupérer les publicités à venir (chercher dans 2024 ET 2025) - TOUTES les publicités // Récupérer les publicités à venir (années N-1, N et N+1) - TOUTES les publicités
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({ const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
queryKey: ['/api/ad-campaigns', 'upcoming'], queryKey: ['/api/ad-campaigns', 'upcoming'],
queryFn: async () => { queryFn: async () => {
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1]; const years = [currentYear - 1, currentYear, currentYear + 1];
let allPublicities: PublicityWithRelations[] = [];
for (const year of years) { // Les trois années sont demandées en parallèle ; une année en échec est ignorée
const publicitiesByYear = await Promise.all(years.map(async (year): Promise<PublicityWithRelations[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('year', year.toString()); params.append('year', year.toString());
// NE PAS filtrer par magasin - on veut toutes les publicités // NE PAS filtrer par magasin - on veut toutes les publicités
try { try {
const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' }); const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' });
if (response.ok) { if (!response.ok) return [];
const yearPublicities = await response.json(); const yearPublicities = await response.json();
allPublicities = [...allPublicities, ...yearPublicities]; return Array.isArray(yearPublicities) ? yearPublicities : [];
}
} catch (error) { } catch (error) {
console.log(`Erreur lors de la récupération des publicités ${year}:`, error); console.warn(`Erreur lors de la récupération des publicités ${year}:`, error);
} return [];
} }
}));
// Filtrer les publicités à venir et les trier par date // Filtrer les publicités à venir et les trier par date
const futurePublicities = allPublicities const now = new Date();
.filter((publicity: any) => new Date(publicity.startDate) > new Date()) const futurePublicities = publicitiesByYear
.flat()
.filter((publicity: any) => new Date(publicity.startDate) > now)
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); .sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return futurePublicities; return futurePublicities;
}, },
enabled: !!user,
}); });
// Fetch DLC stats // Fetch DLC stats
// Clé distincte de celle de DlcPage, qui n'envoie storeId que pour l'admin (réponse
// différente pour un non-admin multi-magasins). Le préfixe reste couvert par les
// invalidations des mutations DLC.
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({ const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
queryKey: ["/api/dlc-products/stats", selectedStoreId], queryKey: ["/api/dlc-products/stats", selectedStoreId, "dashboard"],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
// Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions) // Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions)
@@ -238,6 +200,7 @@ export default function Dashboard() {
credentials: 'include' credentials: 'include'
}).then(res => res.json()); }).then(res => res.json());
}, },
enabled: !!user,
}); });
// Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin // Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin
@@ -287,55 +250,66 @@ export default function Dashboard() {
credentials: 'include' credentials: 'include'
}).then(res => res.json()); }).then(res => res.json());
}, },
enabled: !!user,
}); });
// Données dérivées pour les sections // Données dérivées pour les sections, recalculées seulement quand les données
const recentOrders = Array.isArray(allOrders) ? allOrders // changent. Les tris portent sur des copies : le cache React Query reste intact.
.sort((a: any, b: any) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
})
.slice(0, 4) : []; // Afficher les 4 dernières commandes
// Toutes les commandes en attente // Toutes les commandes en attente, les plus récentes d'abord
const pendingOrders = Array.isArray(allOrders) ? allOrders const pendingOrders = useMemo(() => {
if (!Array.isArray(allOrders)) return [];
const createdTime = (order: any) => safeDate(order.createdAt)?.getTime() ?? 0;
return allOrders
.filter((order: any) => order.status === 'pending') .filter((order: any) => order.status === 'pending')
.sort((a: any, b: any) => { .sort((a: any, b: any) => createdTime(b) - createdTime(a));
const dateA = safeDate(a.createdAt); }, [allOrders]);
const dateB = safeDate(b.createdAt);
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
}) : []; // Afficher toutes les commandes en attente
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries // Livraisons : un seul passage pour les livraisons prévues et les compteurs du mois
.filter((d: any) => d.status === 'planned') const { upcomingDeliveries, deliveredThisMonth, totalPalettes } = useMemo(() => {
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime()) const planned: any[] = [];
.slice(0, 4) : []; let deliveredCount = 0;
let palettes = 0;
if (Array.isArray(allDeliveries)) {
const now = new Date();
for (const delivery of allDeliveries) {
if (delivery.status === 'planned') {
planned.push(delivery);
} else if (delivery.status === 'delivered') {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (deliveryDate && deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
deliveredCount++;
// Total des palettes pour les livraisons 'delivered' du mois en cours
if (delivery.unit === 'palettes') {
palettes += delivery.quantity || 0;
}
}
}
}
planned.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime());
}
return {
upcomingDeliveries: planned.slice(0, 4),
deliveredThisMonth: deliveredCount,
totalPalettes: palettes,
};
}, [allDeliveries]);
// Tâches non terminées, les plus anciennes d'abord (5 au maximum)
const openTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
const createdTime = (task: any) => safeDate(task.createdAt)?.getTime() ?? 0;
return tasks
.filter((task: any) => task.status !== 'completed')
.sort((a: any, b: any) => createdTime(a) - createdTime(b))
.slice(0, 5);
}, [tasks]);
// Calculs pour les statistiques // Calculs pour les statistiques
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0; const pendingOrdersCount = pendingOrders.length;
const averageDeliveryTime = Math.round(yearlyStats?.averageDeliveryTime || 0); const averageDeliveryTime = Math.round(yearlyStats?.averageDeliveryTime || 0);
const deliveredThisMonth = Array.isArray(allDeliveries) ? allDeliveries.filter((delivery: any) => {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (!deliveryDate) return false;
const now = new Date();
return deliveryDate.getMonth() === now.getMonth() &&
deliveryDate.getFullYear() === now.getFullYear() &&
delivery.status === 'delivered';
}).length : 0;
// Calculer le total des palettes pour les livraisons 'delivered' du mois en cours
const totalPalettes = Array.isArray(allDeliveries) ? allDeliveries.reduce((total: number, delivery: any) => {
if (delivery.status === 'delivered' && delivery.unit === 'palettes') {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (!deliveryDate) return total;
const now = new Date();
if (deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
return total + (delivery.quantity || 0);
}
}
return total;
}, 0) : 0;
// Fonction pour obtenir la configuration des priorités (identique au module Tasks) // Fonction pour obtenir la configuration des priorités (identique au module Tasks)
const getPriorityConfig = (priority: string) => { const getPriorityConfig = (priority: string) => {
@@ -367,25 +341,6 @@ export default function Dashboard() {
} }
}; };
// Statistiques pour les commandes clients
const ordersByStatus = {
pending: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'pending').length : 0,
planned: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'planned').length : 0,
delivered: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'delivered').length : 0,
total: Array.isArray(allOrders) ? allOrders.length : 0
};
// Statistiques pour les commandes clients (nouveau module)
const customerOrderStats = {
waiting: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'En attente de Commande').length : 0,
inProgress: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Commande en Cours').length : 0,
available: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Disponible').length : 0,
withdrawn: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Retiré').length : 0,
canceled: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Annulé').length : 0,
total: Array.isArray(customerOrders) ? customerOrders.length : 0
};
return ( return (
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6"> <div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Header */} {/* Header */}
@@ -660,14 +615,7 @@ export default function Dashboard() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
{Array.isArray(tasks) && tasks.length > 0 ? tasks {Array.isArray(tasks) && tasks.length > 0 ? openTasks
.filter((task: any) => task.status !== 'completed')
.sort((a: any, b: any) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateA ? dateA.getTime() : 0) - (dateB ? dateB.getTime() : 0);
})
.slice(0, 5)
.map((task: any) => { .map((task: any) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
+28 -29
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -35,6 +35,9 @@ import ValidateDeliveryModal from "@/components/modals/ValidateDeliveryModal";
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal"; import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
import type { DeliveryWithRelations } from "@shared/schema"; import type { DeliveryWithRelations } from "@shared/schema";
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
const NO_DELIVERIES: DeliveryWithRelations[] = [];
export default function Deliveries() { export default function Deliveries() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -79,38 +82,29 @@ export default function Deliveries() {
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin // CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: deliveriesData = [], isLoading } = useQuery<DeliveryWithRelations[]>({ const { data: deliveriesData = NO_DELIVERIES, isLoading } = useQuery<DeliveryWithRelations[]>({
queryKey: ['/api/deliveries', selectedStoreId, user?.role], queryKey: ['/api/deliveries', selectedStoreId, user?.role],
queryFn: async () => { queryFn: async () => {
const url = deliveriesUrl; const response = await fetch(deliveriesUrl, { credentials: 'include' });
console.log('🚚 Fetching deliveries from:', url);
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch deliveries'); throw new Error('Failed to fetch deliveries');
} }
const data = await response.json(); const data = await response.json();
console.log('🚚 Deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
console.log('🚚 Sample delivery data:', data[0]);
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}, },
}); });
// Production Bug Fix: Ensure array safety for all data operations // Production Bug Fix: Ensure array safety for all data operations
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : []; const deliveries = Array.isArray(deliveriesData) ? deliveriesData : NO_DELIVERIES;
console.log('🚚 Deliveries Debug:', { // Clé construite sur l'URL (tableau de bord admin avec magasin sélectionné).
isLoading, // Sans magasin, elle vaut ['/api/deliveries'], déjà invalidée : la réinvalider
deliveriesCount: deliveries?.length, // relancerait une seconde fois le chargement complet des livraisons.
deliveries: deliveries?.slice(0, 2), const invalidateDeliveriesUrlKey = () => {
selectedStoreId, if (selectedStoreId) {
deliveriesUrl queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
}); }
};
const { data: groupsData = [] } = useQuery({
queryKey: ['/api/groups'],
});
const groups = Array.isArray(groupsData) ? groupsData : [];
const markControlValidatedMutation = useMutation({ const markControlValidatedMutation = useMutation({
@@ -123,7 +117,7 @@ export default function Deliveries() {
description: "Contrôle validé avec succès", description: "Contrôle validé avec succès",
}); });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] }); invalidateDeliveriesUrlKey();
}, },
onError: (error: any) => { onError: (error: any) => {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
@@ -148,8 +142,10 @@ export default function Deliveries() {
}); });
// Invalider tous les caches liés aux livraisons // Invalider tous les caches liés aux livraisons
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] }); invalidateDeliveriesUrlKey();
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] }); queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
// L'échéancier (cache de 5 min) liste les livraisons facturées
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
}, },
onError: (error) => { onError: (error) => {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
@@ -171,16 +167,19 @@ export default function Deliveries() {
}, },
}); });
const filteredDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => { // Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
console.log('🔍 Filtering delivery:', delivery.id, { searchTerm, statusFilter }); const filteredDeliveries = useMemo(() => {
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) || const search = searchTerm.toLowerCase();
delivery.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) || return deliveries.filter(delivery => {
delivery.notes?.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = delivery.supplier?.name.toLowerCase().includes(search) ||
delivery.group?.name.toLowerCase().includes(search) ||
delivery.notes?.toLowerCase().includes(search);
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter; const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}) : []; });
}, [deliveries, searchTerm, statusFilter]);
// Pagination // Pagination
const { const {
+90 -52
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useMutationState, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -37,6 +37,14 @@ const dlcFormSchema = z.object({
type DlcFormData = z.infer<typeof dlcFormSchema>; type DlcFormData = z.infer<typeof dlcFormSchema>;
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
// les listes d'impression à chaque rendu)
const NO_PRODUCTS: DlcProductWithRelations[] = [];
// Préfixe des actions faites depuis une ligne du tableau : sert à savoir
// quelles lignes ont une action en cours
const DLC_ROW_ACTION_KEY = "dlc-row-action";
export default function DlcPage() { export default function DlcPage() {
const { user, isLoading: authLoading } = useAuthUnified(); const { user, isLoading: authLoading } = useAuthUnified();
const { toast } = useToast(); const { toast } = useToast();
@@ -61,7 +69,7 @@ export default function DlcPage() {
}, [searchTerm]); }, [searchTerm]);
// Fetch stores/groups - optimized cache // Fetch stores/groups - optimized cache
const { data: stores = [] } = useQuery({ const { data: stores = [] } = useQuery<any[]>({
queryKey: ["/api/groups"], queryKey: ["/api/groups"],
enabled: !authLoading, enabled: !authLoading,
staleTime: 5 * 60 * 1000, // 5 minutes cache staleTime: 5 * 60 * 1000, // 5 minutes cache
@@ -76,7 +84,7 @@ export default function DlcPage() {
}); });
// Fetch DLC products - optimized with server-side search // Fetch DLC products - optimized with server-side search
const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({ const { data: dlcProducts = NO_PRODUCTS, isLoading: productsLoading, isPlaceholderData } = useQuery({
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm], queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -89,6 +97,11 @@ export default function DlcPage() {
}, },
enabled: !authLoading, enabled: !authLoading,
staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data
// Changement de filtre ou de recherche : le tableau précédent reste affiché
// (estompé, avec un indicateur) au lieu de disparaître. Jamais au
// changement de magasin (produits d'un autre magasin).
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
// Fetch DLC stats - optimized cache // Fetch DLC stats - optimized cache
@@ -175,13 +188,30 @@ export default function DlcPage() {
}; };
}, [gencodeValue, suppliers]); }, [gencodeValue, suppliers]);
// Après une modification, la liste et les compteurs changent : seules les
// requêtes affichées sont rechargées, les autres (autres filtres, tableau de
// bord) sont seulement marquées périmées
const invalidateDlcData = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], refetchType: "active" }),
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], refetchType: "active" }),
]);
// Actions en cours, par produit : seuls les boutons de la ligne concernée sont
// désactivés (et non ceux de toutes les lignes), y compris si plusieurs
// produits sont traités à la suite
const pendingRowActions = useMutationState({
filters: { mutationKey: [DLC_ROW_ACTION_KEY], status: "pending" },
select: (mutation) => `${String(mutation.options.mutationKey?.[1])}:${String(mutation.state.variables)}`,
});
const isRowActionPending = (action: string, productId: number) =>
pendingRowActions.includes(`${action}:${productId}`);
// Create mutation - optimized cache invalidation // Create mutation - optimized cache invalidation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data), mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
onSuccess: () => { onSuccess: () => {
// Selective cache invalidation invalidateDlcData();
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit DLC créé avec succès" }); toast({ title: "Produit DLC créé avec succès" });
setIsDialogOpen(false); setIsDialogOpen(false);
form.reset(); form.reset();
@@ -201,8 +231,7 @@ export default function DlcPage() {
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) => mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
apiRequest(`/api/dlc-products/${id}`, "PUT", data), apiRequest(`/api/dlc-products/${id}`, "PUT", data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false }); invalidateDlcData();
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit DLC mis à jour avec succès" }); toast({ title: "Produit DLC mis à jour avec succès" });
setIsDialogOpen(false); setIsDialogOpen(false);
form.reset(); form.reset();
@@ -219,11 +248,12 @@ export default function DlcPage() {
// Validate mutation - optimized cache invalidation // Validate mutation - optimized cache invalidation
const validateMutation = useMutation({ const validateMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "validate"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit validé avec succès" }); toast({ title: "Produit validé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -236,11 +266,12 @@ export default function DlcPage() {
// Delete mutation - optimized cache invalidation // Delete mutation - optimized cache invalidation
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "delete"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit supprimé avec succès" }); toast({ title: "Produit supprimé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -253,11 +284,12 @@ export default function DlcPage() {
// Stock épuisé mutation - accessible à tous // Stock épuisé mutation - accessible à tous
const markStockEpuiseMutation = useMutation({ const markStockEpuiseMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "stock-epuise"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit marqué comme stock épuisé" }); toast({ title: "Produit marqué comme stock épuisé" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -270,11 +302,12 @@ export default function DlcPage() {
// Restaurer stock mutation - réservé aux admins, directeurs et managers // Restaurer stock mutation - réservé aux admins, directeurs et managers
const restoreStockMutation = useMutation({ const restoreStockMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "restore-stock"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Stock restauré avec succès" }); toast({ title: "Stock restauré avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -287,11 +320,12 @@ export default function DlcPage() {
// Marquer comme traité mutation - accessible à tous // Marquer comme traité mutation - accessible à tous
const markProcessedMutation = useMutation({ const markProcessedMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "mark-processed"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit marqué comme traité" }); toast({ title: "Produit marqué comme traité" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -304,11 +338,12 @@ export default function DlcPage() {
// Annuler traitement mutation - réservé aux admins, directeurs et managers // Annuler traitement mutation - réservé aux admins, directeurs et managers
const unmarkProcessedMutation = useMutation({ const unmarkProcessedMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "unmark-processed"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"), mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Traitement annulé avec succès" }); toast({ title: "Traitement annulé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
@@ -330,36 +365,21 @@ export default function DlcPage() {
if (user?.userGroups?.[0]?.groupId) { if (user?.userGroups?.[0]?.groupId) {
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe // Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
groupId = user.userGroups[0].groupId; groupId = user.userGroups[0].groupId;
console.log("🎯 DLC Frontend: Using user's assigned group (PRIORITY):", groupId);
} else if (user?.role === 'admin' && selectedStoreId) { } else if (user?.role === 'admin' && selectedStoreId) {
// Admin avec magasin sélectionné ET pas de groupe assigné // Admin avec magasin sélectionné ET pas de groupe assigné
groupId = selectedStoreId; groupId = selectedStoreId;
console.log("🎯 DLC Frontend: Using admin selected store:", groupId);
} else if (user?.role === 'admin' && stores?.[0]?.id) { } else if (user?.role === 'admin' && stores?.[0]?.id) {
// Admin sans sélection : premier magasin disponible // Admin sans sélection : premier magasin disponible
groupId = stores[0].id; groupId = stores[0].id;
console.log("🎯 DLC Frontend: Using first available store for admin:", groupId);
} else { } else {
// Fallback d'urgence // Fallback d'urgence
groupId = 1; groupId = 1;
console.log("🚨 DLC Frontend: Using emergency fallback groupId:", groupId);
} }
console.log("🏪 DLC GroupId Selection DEBUG:", { const dlcData: any = {
userRole: user?.role,
selectedStoreId,
userGroups: user?.userGroups?.map(ug => ({groupId: ug.groupId, groupName: ug.group?.name})),
availableStores: stores.map(s => ({id: s.id, name: s.name})),
userGroupsRaw: user?.userGroups,
firstUserGroup: user?.userGroups?.[0],
finalGroupId: groupId,
logicPath: !groupId ? 'need-fallback' : 'already-set'
});
const dlcData: InsertDlcProduct = {
...data, ...data,
name: data.productName, // Copier productName vers name (requis dans la DB) name: data.productName, // Copier productName vers name (requis dans la DB)
expiryDate: dlcDate, expiryDate: dlcDate as any,
quantity: 1, // Valeur par défaut quantity: 1, // Valeur par défaut
unit: "unité", // Valeur par défaut unit: "unité", // Valeur par défaut
location: "Magasin", // Valeur par défaut location: "Magasin", // Valeur par défaut
@@ -377,7 +397,7 @@ export default function DlcPage() {
const handleEdit = (product: DlcProductWithRelations) => { const handleEdit = (product: DlcProductWithRelations) => {
setEditingProduct(product); setEditingProduct(product);
form.reset({ form.reset({
productName: product.productName, productName: product.productName as string,
gencode: product.gencode || "", gencode: product.gencode || "",
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "", dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
dateType: product.dateType as "dlc" | "ddm" | "dluo", dateType: product.dateType as "dlc" | "ddm" | "dluo",
@@ -526,7 +546,7 @@ export default function DlcPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${expiringSoon.map(product => { ${expiringSoon.map((product: any) => {
const today = new Date(); const today = new Date();
const expiry = new Date(product.dlcDate || new Date()); const expiry = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
@@ -585,7 +605,7 @@ export default function DlcPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${expired.map(product => { ${expired.map((product: any) => {
const today = new Date(); const today = new Date();
const expiry = new Date(product.dlcDate || new Date()); const expiry = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
@@ -615,19 +635,19 @@ export default function DlcPage() {
}; };
// Products are already filtered server-side, no client filtering needed // Products are already filtered server-side, no client filtering needed
const filteredProducts = dlcProducts || []; const filteredProducts = dlcProducts || NO_PRODUCTS;
// Memoized calculations for print functions // Memoized calculations for print functions
const { expiringSoonProducts, expiredProducts } = useMemo(() => { const { expiringSoonProducts, expiredProducts } = useMemo(() => {
const today = new Date(); const today = new Date();
const expiringSoon = filteredProducts.filter(product => { const expiringSoon = filteredProducts.filter((product: any) => {
if (product.status === 'valides') return false; if (product.status === 'valides') return false;
const expiryDate = new Date(product.dlcDate || new Date()); const expiryDate = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
return diffDays <= 15 && diffDays > 0; return diffDays <= 15 && diffDays > 0;
}); });
const expired = filteredProducts.filter(product => { const expired = filteredProducts.filter((product: any) => {
if (product.status === 'valides') return false; if (product.status === 'valides') return false;
const expiryDate = new Date(product.dlcDate || new Date()); const expiryDate = new Date(product.dlcDate || new Date());
return expiryDate <= today; return expiryDate <= today;
@@ -647,6 +667,13 @@ export default function DlcPage() {
totalItems totalItems
} = usePagination(filteredProducts, 10); } = usePagination(filteredProducts, 10);
// Retour à la première page quand le magasin, un filtre ou la recherche change
// (le tableau précédent reste affiché pendant le chargement : sa longueur ne
// change pas forcément)
useEffect(() => {
setCurrentPage(1);
}, [selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm, setCurrentPage]);
if (authLoading) { if (authLoading) {
return <div className="flex justify-center items-center h-64">Chargement...</div>; return <div className="flex justify-center items-center h-64">Chargement...</div>;
} }
@@ -926,7 +953,15 @@ export default function DlcPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<CardTitle>Produits DLC ({totalItems})</CardTitle> <CardTitle>
Produits DLC ({totalItems})
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</CardTitle>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Exporter PDF Exporter PDF
@@ -934,14 +969,17 @@ export default function DlcPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{productsLoading ? ( {productsLoading || (isPlaceholderData && totalItems === 0) ? (
<div className="flex justify-center items-center h-32">Chargement des produits...</div> <div className="flex justify-center items-center h-32">Chargement des produits...</div>
) : totalItems === 0 ? ( ) : totalItems === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
Aucun produit DLC trouvé Aucun produit DLC trouvé
</div> </div>
) : ( ) : (
<div> <div
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
aria-busy={isPlaceholderData}
>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -992,7 +1030,7 @@ export default function DlcPage() {
variant="default" variant="default"
size="sm" size="sm"
onClick={() => handleValidate(product.id)} onClick={() => handleValidate(product.id)}
disabled={validateMutation.isPending} disabled={isRowActionPending("validate", product.id)}
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
data-testid={`button-validate-${product.id}`} data-testid={`button-validate-${product.id}`}
title="Valider définitivement ce produit" title="Valider définitivement ce produit"
@@ -1010,7 +1048,7 @@ export default function DlcPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleMarkProcessed(product.id)} onClick={() => handleMarkProcessed(product.id)}
disabled={markProcessedMutation.isPending} disabled={isRowActionPending("mark-processed", product.id)}
className="border-blue-300 text-blue-700 hover:bg-blue-50" className="border-blue-300 text-blue-700 hover:bg-blue-50"
data-testid={`button-mark-processed-${product.id}`} data-testid={`button-mark-processed-${product.id}`}
> >
@@ -1036,7 +1074,7 @@ export default function DlcPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleUnmarkProcessed(product.id)} onClick={() => handleUnmarkProcessed(product.id)}
disabled={unmarkProcessedMutation.isPending} disabled={isRowActionPending("unmark-processed", product.id)}
className="border-orange-300 text-orange-700 hover:bg-orange-50" className="border-orange-300 text-orange-700 hover:bg-orange-50"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -1062,7 +1100,7 @@ export default function DlcPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleMarkStockEpuise(product.id)} onClick={() => handleMarkStockEpuise(product.id)}
disabled={markStockEpuiseMutation.isPending} disabled={isRowActionPending("stock-epuise", product.id)}
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50" className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
> >
<PackageX className="w-4 h-4" /> <PackageX className="w-4 h-4" />
@@ -1087,7 +1125,7 @@ export default function DlcPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleRestoreStock(product.id)} onClick={() => handleRestoreStock(product.id)}
disabled={restoreStockMutation.isPending} disabled={isRowActionPending("restore-stock", product.id)}
className="border-blue-300 text-blue-700 hover:bg-blue-50" className="border-blue-300 text-blue-700 hover:bg-blue-50"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
@@ -1106,7 +1144,7 @@ export default function DlcPage() {
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => handleDelete(product.id)} onClick={() => handleDelete(product.id)}
disabled={deleteMutation.isPending} disabled={isRowActionPending("delete", product.id)}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
+291
View File
@@ -0,0 +1,291 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react";
interface ApiKey {
id: number;
name: string;
keyPrefix: string;
createdAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
}
interface ApiKeysResponse {
basePath: string;
envKeyCount: number;
keys: ApiKey[];
}
const QUERY_KEY = ["/api/external-api/keys"];
function formatDateTime(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest
function errorMessage(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
const json = raw.slice(raw.indexOf(":") + 1).trim();
try {
return JSON.parse(json).error || raw;
} catch {
return raw;
}
}
function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" });
}
};
return (
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label} className="shrink-0">
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
<span className="ml-1.5">{copied ? "Copié" : "Copier"}</span>
</Button>
);
}
export default function ExternalApiSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const { data, isLoading, error } = useQuery<ApiKeysResponse>({ queryKey: QUERY_KEY });
const createMutation = useMutation({
mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }),
onSuccess: (result: { key: string; apiKey: ApiKey }) => {
setCreatedKey({ name: result.apiKey.name, key: result.key });
setNewKeyName("");
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }),
});
const revokeMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"),
onSuccess: () => {
toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." });
setKeyToRevoke(null);
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }),
});
const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`;
const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? [];
const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? [];
const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0;
const handleCreate = (event: React.FormEvent) => {
event.preventDefault();
const name = newKeyName.trim();
if (name) createMutation.mutate(name);
};
return (
<div className="h-full overflow-y-auto space-y-6">
{/* Présentation et adresse */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="w-5 h-5 text-blue-600" />
API externe de rapprochement
</CardTitle>
<CardDescription>
Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL
des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-700">État :</span>
{isLoading ? (
<span className="text-sm text-gray-600">Chargement…</span>
) : apiEnabled ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
) : (
<Badge variant="outline" className="border-orange-300 text-orange-800">Désactivée : aucune clé active</Badge>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="api-url">Adresse de l'API</Label>
<div className="flex gap-2">
<Input id="api-url" value={apiUrl} readOnly className="font-mono text-sm" />
<CopyButton value={apiUrl} label="Copier l'adresse de l'API" />
</div>
<p className="text-sm text-gray-600">
La clé s'envoie dans l'en-tête <code className="rounded bg-gray-100 px-1">X-API-Key</code>{" "}
(ou <code className="rounded bg-gray-100 px-1">Authorization: Bearer …</code>).
</p>
</div>
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-700 space-y-1">
<p className="font-medium text-gray-900">Principales routes</p>
<ul className="space-y-0.5 font-mono text-xs sm:text-sm">
<li>GET /stores — magasins</li>
<li>GET /suppliers — fournisseurs</li>
<li>GET /deliveries?supplierId=…&amp;hasInvoice=false — livraisons et n° de BL</li>
<li>PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance</li>
</ul>
<p className="pt-1">Documentation complète : <code className="rounded bg-white px-1">docs/API-RAPPROCHEMENT.md</code></p>
</div>
</CardContent>
</Card>
{/* Clés */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-blue-600" />
Clés d'API
</CardTitle>
<CardDescription>
Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres.
La clé n'est affichée qu'une seule fois, à sa création.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1 space-y-1.5">
<Label htmlFor="api-key-name">Nom de la nouvelle clé</Label>
<Input
id="api-key-name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Ex. : n8n comptabilité"
maxLength={100}
/>
</div>
<Button type="submit" disabled={!newKeyName.trim() || createMutation.isPending}>
<Plus className="w-4 h-4 mr-1.5" />
{createMutation.isPending ? "Création…" : "Créer une clé"}
</Button>
</form>
{error ? (
<p className="text-sm text-red-700">Impossible de charger les clés : {errorMessage(error)}</p>
) : isLoading ? (
<p className="text-sm text-gray-600">Chargement…</p>
) : activeKeys.length === 0 ? (
<p className="text-sm text-gray-600">Aucune clé active.</p>
) : (
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
{activeKeys.map((apiKey) => (
<li key={apiKey.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900 truncate">{apiKey.name}</p>
<p className="text-sm text-gray-600">
<code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code>
{" · "}créée le {formatDateTime(apiKey.createdAt)}
{" · "}
{apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"}
</p>
</div>
<Button
variant="outline"
size="sm"
className="self-start sm:self-auto text-red-700 border-red-200 hover:bg-red-50"
onClick={() => setKeyToRevoke(apiKey)}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Révoquer
</Button>
</li>
))}
</ul>
)}
{(data?.envKeyCount ?? 0) > 0 && (
<p className="text-sm text-gray-600">
{data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans
la variable d'environnement <code className="rounded bg-gray-100 px-1">EXTERNAL_API_KEYS</code> du serveur
(non modifiables ici).
</p>
)}
{revokedKeys.length > 0 && (
<details className="text-sm text-gray-600">
<summary className="cursor-pointer">Clés révoquées ({revokedKeys.length})</summary>
<ul className="mt-2 space-y-1">
{revokedKeys.map((apiKey) => (
<li key={apiKey.id}>
{apiKey.name} · <code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code> · révoquée le {formatDateTime(apiKey.revokedAt)}
</li>
))}
</ul>
</details>
)}
</CardContent>
</Card>
{/* Clé créée : affichée une seule fois */}
<Dialog open={!!createdKey} onOpenChange={(open) => { if (!open) setCreatedKey(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Clé « {createdKey?.name} » créée</DialogTitle>
<DialogDescription>
Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input value={createdKey?.key ?? ""} readOnly className="font-mono text-sm" onFocus={(e) => e.currentTarget.select()} aria-label="Clé d'API" />
{createdKey && <CopyButton value={createdKey.key} label="Copier la clé d'API" />}
</div>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<p>Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.</p>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>J'ai copié la clé</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmation de révocation */}
<Dialog open={!!keyToRevoke} onOpenChange={(open) => { if (!open) setKeyToRevoke(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Révoquer la clé « {keyToRevoke?.name} » ?</DialogTitle>
<DialogDescription>
Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>Annuler</Button>
<Button
variant="destructive"
disabled={revokeMutation.isPending}
onClick={() => keyToRevoke && revokeMutation.mutate(keyToRevoke.id)}
>
{revokeMutation.isPending ? "Révocation…" : "Révoquer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+337 -133
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -18,7 +19,7 @@ import {
Truck, Truck,
Palette Palette
} from "lucide-react"; } from "lucide-react";
import type { Group, NocodbConfig } from "@shared/schema"; import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema";
const colorOptions = [ const colorOptions = [
{ value: '#1976D2', label: 'Bleu' }, { value: '#1976D2', label: 'Bleu' },
@@ -33,6 +34,38 @@ const colorOptions = [
{ value: '#00796B', label: 'Sarcelle' }, { value: '#00796B', label: 'Sarcelle' },
]; ];
// Taille maximale du logo magasin : il est encodé en base64 dans la fiche
// et joint à chaque mail, un fichier lourd alourdirait tous les envois.
const MAX_LOGO_KB = 200;
// Valeurs par défaut du formulaire magasin (création et réinitialisations)
const EMPTY_GROUP_FORM = {
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
// Coordonnées reprises dans la signature des mails fournisseurs
address: "",
phone: "",
logo: "",
// Configuration SMTP propre au magasin
smtpEnabled: false,
smtpHost: "",
smtpPort: "",
smtpSecure: false,
smtpUser: "",
smtpPassword: "",
smtpSenderEmail: "",
smtpSenderName: "",
};
export default function Groups() { export default function Groups() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { toast } = useToast(); const { toast } = useToast();
@@ -42,55 +75,33 @@ export default function Groups() {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null); const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
const { data: groups = [], isLoading } = useQuery<Group[]>({ const { data: groups = [], isLoading } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
}); });
// Configurations NocoDB (liste déroulante de la fenêtre magasin) : réservées à
// l'admin, le serveur répond 403 aux autres rôles. Les mutations de la page
// NocoDB invalident déjà cette clé.
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({ const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
queryKey: ['/api/nocodb-config'], queryKey: ['/api/nocodb-config'],
enabled: user?.role === 'admin',
staleTime: 5 * 60 * 1000,
}); });
// Protection renforcée contre les erreurs TypeError // Protection renforcée contre les erreurs TypeError
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : []; const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
console.log('🔍 Groups NocoDB Debug:', { // Comptages agrégés en base : la page n'affiche que des totaux, inutile de
rawNocodbConfigs, // rapatrier l'historique complet des commandes et des livraisons.
rawType: typeof rawNocodbConfigs, const { data: groupStats = [] } = useQuery<EntityActivityStats[]>({
nocodbConfigs, queryKey: ['/api/stats/by-group'],
configsType: typeof nocodbConfigs,
isArray: Array.isArray(rawNocodbConfigs),
length: nocodbConfigs.length,
environment: window.location.hostname
});
const { data: orders = [] } = useQuery({
queryKey: ['/api/orders'],
});
const { data: deliveries = [] } = useQuery({
queryKey: ['/api/deliveries'],
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
console.log('🏪 Frontend: Creating group with data:', data); return await apiRequest("/api/groups", "POST", data);
const result = await apiRequest("/api/groups", "POST", data);
console.log('🏪 Frontend: Group creation result:', result);
return result;
}, },
onSuccess: () => { onSuccess: () => {
toast({ toast({
@@ -99,19 +110,7 @@ export default function Groups() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowCreateModal(false); setShowCreateModal(false);
setFormData({ setFormData({ ...EMPTY_GROUP_FORM });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
}, },
onError: (error) => { onError: (error) => {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
@@ -145,19 +144,7 @@ export default function Groups() {
queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowEditModal(false); setShowEditModal(false);
setSelectedGroup(null); setSelectedGroup(null);
setFormData({ setFormData({ ...EMPTY_GROUP_FORM });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
}, },
onError: (error) => { onError: (error) => {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
@@ -179,6 +166,53 @@ export default function Groups() {
}, },
}); });
// Test de la connexion SMTP du magasin (aucun message envoyé)
const testSmtpMutation = useMutation({
mutationFn: async (groupId: number) => {
return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST');
},
onSuccess: (result: any) => {
toast({
title: "Connexion réussie",
description: result?.message || "Le serveur SMTP répond correctement",
});
},
onError: (error: any) => {
toast({
title: "Échec de la connexion",
description: error?.message || "Impossible de joindre le serveur SMTP",
variant: "destructive",
});
},
});
// Lecture du logo en data URI : stocké tel quel sur la fiche magasin
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_LOGO_KB * 1024) {
toast({
title: "Logo trop volumineux",
description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`,
variant: "destructive",
});
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => handleChange('logo', String(reader.result || ''));
reader.onerror = () => {
toast({
title: "Lecture impossible",
description: "Le fichier n'a pas pu être lu",
variant: "destructive",
});
};
reader.readAsDataURL(file);
};
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
await apiRequest(`/api/groups/${id}`, "DELETE"); await apiRequest(`/api/groups/${id}`, "DELETE");
@@ -214,59 +248,20 @@ export default function Groups() {
group.name.toLowerCase().includes(searchTerm.toLowerCase()) group.name.toLowerCase().includes(searchTerm.toLowerCase())
) : []; ) : [];
console.log('🏪 Groups Debug:', {
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
groupsData: groups?.slice(0, 2),
ordersData: orders?.slice(0, 2),
deliveriesData: deliveries?.slice(0, 2)
});
const getGroupStats = (groupId: number) => { const getGroupStats = (groupId: number) => {
const groupOrders = Array.isArray(orders) ? orders.filter(order => { const stats = Array.isArray(groupStats)
const match = order.groupId === groupId; ? groupStats.find(s => s.id === groupId)
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order); : undefined;
return match;
}) : [];
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
const match = delivery.groupId === groupId;
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
return match;
}) : [];
console.log(`🏪 Group ${groupId} stats:`, {
groupOrders: groupOrders.length,
groupDeliveries: groupDeliveries.length,
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
ordersSample: groupOrders.slice(0, 2),
deliveriesSample: groupDeliveries.slice(0, 2),
allOrdersCount: orders?.length,
allDeliveriesCount: deliveries?.length
});
return { return {
orders: groupOrders.length, orders: stats?.orders ?? 0,
deliveries: groupDeliveries.length, deliveries: stats?.deliveries ?? 0,
delivered: groupDeliveries.filter(d => d.status === 'delivered').length, delivered: stats?.delivered ?? 0,
}; };
}; };
const handleCreate = () => { const handleCreate = () => {
setFormData({ setFormData({ ...EMPTY_GROUP_FORM });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setShowCreateModal(true); setShowCreateModal(true);
}; };
@@ -284,6 +279,18 @@ export default function Groups() {
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "", nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "", nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
webhookUrl: group.webhookUrl || "", webhookUrl: group.webhookUrl || "",
address: (group as any).address || "",
phone: (group as any).phone || "",
logo: (group as any).logo || "",
smtpEnabled: Boolean((group as any).smtpEnabled),
smtpHost: (group as any).smtpHost || "",
smtpPort: (group as any).smtpPort?.toString() || "",
smtpSecure: Boolean((group as any).smtpSecure),
smtpUser: (group as any).smtpUser || "",
// Jamais renvoyé par l'API : vide = mot de passe inchangé
smtpPassword: "",
smtpSenderEmail: (group as any).smtpSenderEmail || "",
smtpSenderName: (group as any).smtpSenderName || "",
}); });
setShowEditModal(true); setShowEditModal(true);
}; };
@@ -318,11 +325,17 @@ export default function Groups() {
} }
// Prepare data with proper type conversion // Prepare data with proper type conversion
const submitData = { const submitData: any = {
...formData, ...formData,
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null, nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null,
}; };
// Mot de passe laissé vide = on conserve celui déjà enregistré
if (!formData.smtpPassword) {
delete submitData.smtpPassword;
}
if (selectedGroup) { if (selectedGroup) {
updateMutation.mutate(submitData); updateMutation.mutate(submitData);
} else { } else {
@@ -330,7 +343,7 @@ export default function Groups() {
} }
}; };
const handleChange = (field: string, value: string) => { const handleChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
@@ -413,7 +426,7 @@ export default function Groups() {
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-10 h-10 flex items-center justify-center mr-3" className="w-10 h-10 flex items-center justify-center mr-3"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
> >
<Users className="w-5 h-5 text-white" /> <Users className="w-5 h-5 text-white" />
</div> </div>
@@ -448,7 +461,7 @@ export default function Groups() {
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-4 h-4 rounded mr-2" className="w-4 h-4 rounded mr-2"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
/> />
<span className="text-sm text-gray-600">{group.color}</span> <span className="text-sm text-gray-600">{group.color}</span>
</div> </div>
@@ -492,16 +505,9 @@ export default function Groups() {
setShowCreateModal(false); setShowCreateModal(false);
setShowEditModal(false); setShowEditModal(false);
setSelectedGroup(null); setSelectedGroup(null);
setFormData({ setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableId: "",
nocodbTableName: "",
invoiceColumnName: "Ref Facture",
});
}}> }}>
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description"> <DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto" aria-describedby="group-modal-description">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe {selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
@@ -512,6 +518,9 @@ export default function Groups() {
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 items-start">
{/* Colonne gauche : identité + configuration NocoDB */}
<div className="space-y-4">
<div> <div>
<Label htmlFor="name">Nom du groupe *</Label> <Label htmlFor="name">Nom du groupe *</Label>
<Input <Input
@@ -712,7 +721,214 @@ export default function Groups() {
)} )}
</div> </div>
<div className="flex items-center space-x-3 pt-4"> </div>
{/* Colonne droite : coordonnées + envoi de mails */}
<div className="space-y-4">
{/* Section Coordonnées du magasin (signature des mails) */}
<div className="border-t md:border-t-0 pt-4 md:pt-0 space-y-4">
<div className="flex items-center space-x-2 mb-1">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
</div>
<p className="text-sm text-gray-600">
Reprises dans la signature des mails envoyés aux fournisseurs.
</p>
<div>
<Label htmlFor="address">Adresse</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder={"12 rue des Fleurs\n54000 Nancy"}
rows={2}
/>
</div>
<div>
<Label htmlFor="phone">Téléphone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="03 83 00 00 00"
/>
</div>
<div>
<Label htmlFor="logo">Logo</Label>
<div className="flex items-center space-x-3 mt-1">
{formData.logo ? (
<img
src={formData.logo}
alt="Logo du magasin"
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
/>
) : (
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
Aucun logo
</div>
)}
<div className="space-y-1">
<Input
id="logo"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
className="text-xs"
/>
{formData.logo && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-red-600 hover:text-red-700"
onClick={() => handleChange('logo', '')}
>
Retirer le logo
</Button>
)}
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
</p>
</div>
</div>
{/* Section Configuration SMTP du magasin */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-purple-500 rounded"></div>
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpEnabled}
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Activé</span>
</label>
</div>
<p className="text-sm text-gray-600">
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
</p>
{formData.smtpEnabled && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
<Input
id="smtpHost"
value={formData.smtpHost}
onChange={(e) => handleChange('smtpHost', e.target.value)}
placeholder="smtp.office365.com"
/>
</div>
<div>
<Label htmlFor="smtpPort">Port *</Label>
<Input
id="smtpPort"
type="number"
value={formData.smtpPort}
onChange={(e) => handleChange('smtpPort', e.target.value)}
placeholder="587"
/>
</div>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpSecure}
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpUser">Identifiant</Label>
<Input
id="smtpUser"
value={formData.smtpUser}
onChange={(e) => handleChange('smtpUser', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="smtpPassword">Mot de passe</Label>
<Input
id="smtpPassword"
type="password"
value={formData.smtpPassword}
onChange={(e) => handleChange('smtpPassword', e.target.value)}
placeholder={
selectedGroup && (selectedGroup as any).smtpPasswordSet
? "Inchangé"
: "Mot de passe"
}
autoComplete="new-password"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
<Input
id="smtpSenderEmail"
type="email"
value={formData.smtpSenderEmail}
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
/>
</div>
<div>
<Label htmlFor="smtpSenderName">Nom affiché</Label>
<Input
id="smtpSenderName"
value={formData.smtpSenderName}
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
placeholder={formData.name || "LaFoir'Fouille Nancy"}
/>
</div>
</div>
{selectedGroup && (
<div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={testSmtpMutation.isPending}
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
>
{testSmtpMutation.isPending
? "Test en cours..."
: "✉️ Tester la connexion SMTP"}
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-3 pt-4 border-t">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -720,19 +936,7 @@ export default function Groups() {
setShowCreateModal(false); setShowCreateModal(false);
setShowEditModal(false); setShowEditModal(false);
setSelectedGroup(null); setSelectedGroup(null);
setFormData({ setFormData({ ...EMPTY_GROUP_FORM });
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
}} }}
> >
Annuler Annuler
+2 -17
View File
@@ -53,7 +53,7 @@ export default function NocoDBConfig() {
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({}); const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
// Queries avec protection complète // Queries avec protection complète
const { data: rawConfigs, isLoading, error } = useQuery({ const { data: rawConfigs, isLoading } = useQuery({
queryKey: ['/api/nocodb-config'], queryKey: ['/api/nocodb-config'],
enabled: user?.role === 'admin', enabled: user?.role === 'admin',
}); });
@@ -62,21 +62,6 @@ export default function NocoDBConfig() {
const configs = rawConfigs || []; const configs = rawConfigs || [];
const safeConfigs = Array.isArray(configs) ? configs : []; const safeConfigs = Array.isArray(configs) ? configs : [];
// Log pour debug production avec plus de détails
console.log('🔍 NocoDBConfig Debug:', {
rawConfigs,
rawConfigsType: typeof rawConfigs,
configs,
configsType: typeof configs,
isArray: Array.isArray(configs),
safeConfigs,
safeConfigsType: typeof safeConfigs,
length: safeConfigs.length,
error,
userRole: user?.role,
environment: window.location.hostname
});
// Mutations // Mutations
const createConfigMutation = useMutation({ const createConfigMutation = useMutation({
mutationFn: (data: NocodbConfigForm) => mutationFn: (data: NocodbConfigForm) =>
@@ -183,7 +168,7 @@ export default function NocoDBConfig() {
apiToken: config.apiToken, apiToken: config.apiToken,
projectId: config.projectId, projectId: config.projectId,
description: config.description || "", description: config.description || "",
isActive: config.isActive, isActive: config.isActive ?? undefined,
}); });
setShowEditModal(true); setShowEditModal(true);
}; };
+18 -49
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -30,6 +30,9 @@ import OrderDetailModal from "@/components/modals/OrderDetailModal";
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal"; import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
import type { OrderWithRelations } from "@shared/schema"; import type { OrderWithRelations } from "@shared/schema";
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
const NO_ORDERS: OrderWithRelations[] = [];
export default function Orders() { export default function Orders() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -73,7 +76,7 @@ export default function Orders() {
const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: ordersData = [], isLoading } = useQuery<OrderWithRelations[]>({ const { data: ordersData = NO_ORDERS, isLoading } = useQuery<OrderWithRelations[]>({
queryKey: [ordersUrl, selectedStoreId], queryKey: [ordersUrl, selectedStoreId],
queryFn: async () => { queryFn: async () => {
const response = await fetch(ordersUrl, { credentials: 'include' }); const response = await fetch(ordersUrl, { credentials: 'include' });
@@ -81,69 +84,32 @@ export default function Orders() {
throw new Error('Failed to fetch orders'); throw new Error('Failed to fetch orders');
} }
const data = await response.json(); const data = await response.json();
console.log('📦 Orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
console.log('📦 Sample order data:', data[0]);
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}, },
}); });
// Production Bug Fix: Ensure array safety for all data operations // Production Bug Fix: Ensure array safety for all data operations
const orders = Array.isArray(ordersData) ? ordersData : []; const orders = Array.isArray(ordersData) ? ordersData : NO_ORDERS;
console.log('📦 Orders Debug:', {
isLoading,
ordersCount: orders?.length,
orders: orders?.slice(0, 2),
selectedStoreId,
ordersUrl
});
const { data: groupsData = [] } = useQuery({
queryKey: ['/api/groups'],
});
const groups = Array.isArray(groupsData) ? groupsData : [];
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
console.log('🗑️ Deleting order:', id);
await apiRequest(`/api/orders/${id}`, "DELETE"); await apiRequest(`/api/orders/${id}`, "DELETE");
}, },
onSuccess: () => { onSuccess: () => {
console.log('✅ Order deleted successfully, invalidating cache...');
toast({ toast({
title: "Succès", title: "Succès",
description: "Commande supprimée avec succès", description: "Commande supprimée avec succès",
}); });
console.log('🗑️ Order deleted, clearing ALL cache to avoid inconsistencies:', { // Invalidation ciblée sans clear() pour préserver le contexte : les requêtes
ordersUrl, // affichées sont rechargées tout de suite, les autres (mois du calendrier
selectedStoreId // déjà visités, autres pages) seulement quand elles seront réaffichées
});
// Sauvegarder le selectedStoreId avant le nettoyage
if (selectedStoreId) {
localStorage.setItem('selectedStoreId', selectedStoreId.toString());
}
// SOLUTION HYBRIDE : Invalidation sélective pour éviter perte storeId
console.log('🧹 Using selective invalidation to preserve storeId context...');
// Invalidation ciblée sans clear() pour préserver le contexte
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (query) => { predicate: (query) => {
const key = query.queryKey[0]?.toString() || ''; const key = query.queryKey[0]?.toString() || '';
return key.includes('/api/orders') || key.includes('/api/deliveries'); return key.includes('/api/orders') || key.includes('/api/deliveries');
} }
}); });
// Force refetch pour garantir synchronisation immédiate
queryClient.refetchQueries({
predicate: (query) => {
const key = query.queryKey[0]?.toString() || '';
return key.includes('/api/orders') || key.includes('/api/deliveries');
}
});
}, },
onError: (error) => { onError: (error) => {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
@@ -165,16 +131,19 @@ export default function Orders() {
}, },
}); });
const filteredOrders = Array.isArray(orders) ? orders.filter(order => { // Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter }); const filteredOrders = useMemo(() => {
const matchesSearch = order.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) || const search = searchTerm.toLowerCase();
order.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) || return orders.filter(order => {
order.notes?.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = order.supplier?.name.toLowerCase().includes(search) ||
order.group?.name.toLowerCase().includes(search) ||
order.notes?.toLowerCase().includes(search);
const matchesStatus = statusFilter === "all" || order.status === statusFilter; const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}) : []; });
}, [orders, searchTerm, statusFilter]);
// Pagination // Pagination
const { const {
+50 -35
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -53,6 +53,7 @@ interface PaymentScheduleResponse {
export default function PaymentSchedulePage() { export default function PaymentSchedulePage() {
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedMonth, setSelectedMonth] = useState<string>(() => { const [selectedMonth, setSelectedMonth] = useState<string>(() => {
const now = new Date(); const now = new Date();
return format(now, 'yyyy-MM'); return format(now, 'yyyy-MM');
@@ -75,7 +76,12 @@ export default function PaymentSchedulePage() {
if (!response.ok) { if (!response.ok) {
throw new Error(`Erreur ${response.status}: ${response.statusText}`); throw new Error(`Erreur ${response.status}: ${response.statusText}`);
} }
return response.json(); const result = await response.json();
// Ce calcul peut compléter en base les échéances et montants manquants des
// livraisons : les listes de livraisons en cache sont marquées périmées
// (rechargées à leur prochain affichage, pas immédiatement)
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'], refetchType: 'none' });
return result;
}, },
enabled: !!selectedStoreId, enabled: !!selectedStoreId,
}); });
@@ -251,11 +257,51 @@ export default function PaymentSchedulePage() {
); );
} }
// En-tête (titre, export, choix du mois) : conservé pendant le chargement,
// notamment au changement de magasin, au lieu d'un écran entièrement vide
const header = (
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleOpenExportModal}
variant="outline"
className="gap-2"
disabled={filteredSchedules.length === 0}
data-testid="button-export-csv"
>
<FileSpreadsheet className="h-4 w-4" />
Exporter CSV
</Button>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="p-6 space-y-6">
{header}
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" /> <Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div> </div>
</div>
); );
} }
@@ -292,38 +338,7 @@ export default function PaymentSchedulePage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* En-tête */} {/* En-tête */}
<div className="flex justify-between items-center"> {header}
<div>
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleOpenExportModal}
variant="outline"
className="gap-2"
disabled={filteredSchedules.length === 0}
data-testid="button-export-csv"
>
<FileSpreadsheet className="h-4 w-4" />
Exporter CSV
</Button>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Statistiques du mois */} {/* Statistiques du mois */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+118 -132
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List, Download } from "lucide-react"; import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List, Download } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,12 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, isWithinInterval, startOfYear, endOfYear, getWeek, getMonth } from "date-fns"; import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, startOfYear, endOfYear, getMonth } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { safeFormat, safeDate, safeCompareDate } from "@/lib/dateUtils"; import { safeFormat, safeDate } from "@/lib/dateUtils";
import type { PublicityWithRelations, Group } from "@shared/schema"; import type { PublicityWithRelations, Group } from "@shared/schema";
import PublicityForm from "@/components/PublicityForm"; import PublicityForm from "@/components/PublicityForm";
// Références stables tant que les données ne sont pas chargées (dépendances des useMemo)
const NO_PUBLICITIES: PublicityWithRelations[] = [];
const NO_GROUPS: Group[] = [];
export default function Publicities() { export default function Publicities() {
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut
@@ -39,7 +43,7 @@ export default function Publicities() {
const startYear = currentYear - 2; const startYear = currentYear - 2;
const yearOptions = Array.from({ length: 13 }, (_, i) => startYear + i); const yearOptions = Array.from({ length: 13 }, (_, i) => startYear + i);
const { data: publicities = [], isLoading } = useQuery<PublicityWithRelations[]>({ const { data: publicities = NO_PUBLICITIES, isLoading, isPlaceholderData } = useQuery<PublicityWithRelations[]>({
queryKey: ['/api/ad-campaigns', selectedYear, selectedStoreId], queryKey: ['/api/ad-campaigns', selectedYear, selectedStoreId],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -52,43 +56,18 @@ export default function Publicities() {
throw new Error('Failed to fetch publicities'); throw new Error('Failed to fetch publicities');
} }
const data = await response.json(); const data = await response.json();
console.log('📢 Publicities received:', data.length, 'items', data.slice(0, 2)); // Liste déjà triée par N° côté serveur (même tri numérique) : pas de second tri ici
return Array.isArray(data) ? data : [];
// CRITICAL FIX: Force frontend sorting by pubNumber to ensure correct order
const sortedData = Array.isArray(data)
? data.sort((a, b) => {
const numA = parseInt(a.pubNumber) || 0;
const numB = parseInt(b.pubNumber) || 0;
return numA - numB;
})
: [];
console.log('📢 Publicities after frontend sort:', {
first5: sortedData.slice(0, 5).map(p => `N°${p.pubNumber}`),
total: sortedData.length
});
return sortedData;
}, },
// Les créations, modifications et suppressions invalident déjà cette clé
staleTime: 5 * 60 * 1000,
// Changement d'année : garder la liste affichée (atténuée) pendant le
// chargement, mais jamais au changement de magasin
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[2] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
console.log('📢 Publicities Debug:', { const { data: groups = NO_GROUPS } = useQuery<Group[]>({
isLoading,
publicitiesCount: publicities?.length,
selectedYear,
selectedStoreId,
publicities: publicities?.slice(0, 2)
});
console.log('📢 Publicities Debug:', {
isLoading,
publicitiesCount: publicities?.length,
selectedYear,
selectedStoreId,
publicities: publicities?.slice(0, 2)
});
const { data: groups = [] } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
enabled: !!user, enabled: !!user,
}); });
@@ -96,8 +75,6 @@ export default function Publicities() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
// Try POST first (for production environments that block DELETE) // Try POST first (for production environments that block DELETE)
console.log(`🗑️ [Frontend] Attempting to delete publicity ${id} using POST method`);
try { try {
const response = await fetch(`/api/ad-campaigns/${id}/delete`, { const response = await fetch(`/api/ad-campaigns/${id}/delete`, {
method: 'POST', method: 'POST',
@@ -106,11 +83,9 @@ export default function Publicities() {
}); });
if (response.ok) { if (response.ok) {
console.log(`✅ [Frontend] Successfully deleted publicity ${id} using POST`);
return response.json(); return response.json();
} else if (response.status === 404) { } else if (response.status === 404) {
// Route not found, try DELETE method as fallback (for dev environment) // Route not found, try DELETE method as fallback (for dev environment)
console.log(`⚠️ [Frontend] POST route not found, trying DELETE method`);
const deleteResponse = await fetch(`/api/ad-campaigns/${id}`, { method: 'DELETE' }); const deleteResponse = await fetch(`/api/ad-campaigns/${id}`, { method: 'DELETE' });
if (!deleteResponse.ok) { if (!deleteResponse.ok) {
@@ -152,47 +127,34 @@ export default function Publicities() {
label: format(new Date(selectedYear, i, 1), 'MMMM', { locale: fr }) label: format(new Date(selectedYear, i, 1), 'MMMM', { locale: fr })
})); }));
// Calendar helper functions // Magasins indexés par id (couleur et nom des pastilles)
const getWeekParticipation = (weekStart: Date, weekEnd: Date) => { const groupsById = useMemo(() => new Map(groups.map(group => [group.id, group])), [groups]);
// Vérifier que publicities est un tableau
if (!Array.isArray(publicities)) {
return {
publicities: [],
participatingStores: [],
storeColors: []
};
}
const weekPublicities = publicities.filter(pub => { // Dates de début et de fin converties une seule fois par publicité, et non
const pubStart = safeDate(pub.startDate); // pour chaque semaine de la vue d'ensemble ou chaque jour du calendrier
const pubEnd = safeDate(pub.endDate); const publicityPeriods = useMemo(() => {
if (!Array.isArray(publicities)) return [];
return publicities.map(pub => ({
pub,
start: safeDate(pub.startDate),
end: safeDate(pub.endDate),
}));
}, [publicities]);
// More robust overlap detection: check if any part of the publicity period overlaps with the week // Vue d'ensemble : semaines de l'année regroupées par mois, calculées
return pubStart && pubEnd && (pubStart <= weekEnd && pubEnd >= weekStart); // seulement quand la vue est affichée et que l'année ou les données changent
}); const weeksByMonth = useMemo(() => {
const byMonth = Array.from({ length: 12 }, () => [] as Array<{
weekStart: Date;
weekEnd: Date;
weekNumber: number;
month: number;
publicities: PublicityWithRelations[];
participatingStores: number[];
storeColors: string[];
}>);
if (!showYearOverview) return byMonth;
const participatingStores = new Set<number>();
weekPublicities.forEach(pub => {
// Vérifier que participations existe et est un tableau
if (pub.participations && Array.isArray(pub.participations)) {
pub.participations.forEach(participation => {
participatingStores.add(participation.groupId);
});
}
});
return {
publicities: weekPublicities,
participatingStores: Array.from(participatingStores),
storeColors: Array.from(participatingStores).map(storeId => {
const group = groups.find(g => g.id === storeId);
return group?.color || '#666666';
})
};
};
// Year overview - Generate weeks for the selected year
const getYearWeeks = () => {
const yearStart = startOfYear(new Date(selectedYear, 0, 1)); const yearStart = startOfYear(new Date(selectedYear, 0, 1));
const yearEnd = endOfYear(new Date(selectedYear, 11, 31)); const yearEnd = endOfYear(new Date(selectedYear, 11, 31));
@@ -201,7 +163,7 @@ export default function Publicities() {
{ weekStartsOn: 1 } { weekStartsOn: 1 }
); );
return weeks.map((weekStart, index) => { weeks.forEach((weekStart, index) => {
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }); const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
const weekNumber = index + 1; const weekNumber = index + 1;
@@ -214,19 +176,72 @@ export default function Publicities() {
month = 11; // Décembre month = 11; // Décembre
} }
const participation = getWeekParticipation(weekStart, weekEnd); // Une publicité compte pour la semaine dès que sa période chevauche la semaine
const weekPublicities = publicityPeriods
.filter(({ start, end }) => start && end && (start <= weekEnd && end >= weekStart))
.map(({ pub }) => pub);
return { const participatingStores = new Set<number>();
weekPublicities.forEach(pub => {
// Vérifier que participations existe et est un tableau
if (pub.participations && Array.isArray(pub.participations)) {
pub.participations.forEach(participation => {
participatingStores.add(participation.groupId);
});
}
});
byMonth[month].push({
weekStart, weekStart,
weekEnd, weekEnd,
weekNumber, weekNumber,
month, month,
...participation publicities: weekPublicities,
participatingStores: Array.from(participatingStores),
storeColors: Array.from(participatingStores).map(storeId =>
groupsById.get(storeId)?.color || '#666666'
),
});
});
return byMonth;
}, [showYearOverview, selectedYear, publicityPeriods, groupsById]);
// Vue calendrier : jours du mois sélectionné avec leurs publicités, calculés
// seulement quand cette vue est affichée
const calendarDays = useMemo(() => {
if (viewMode !== 'calendar') return [];
const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1));
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: startDate, end: endDate }).map(day => {
const dayPublicities = publicityPeriods
.filter(({ start, end }) => start && end && day >= start && day <= end)
.map(({ pub }) => pub);
const participatingStores = new Set<number>();
dayPublicities.forEach(pub => {
pub.participations.forEach(participation => {
participatingStores.add(participation.groupId);
});
});
return {
day,
isCurrentMonth: isSameMonth(day, monthStart),
dayPublicities,
participatingStores: Array.from(participatingStores),
}; };
}); });
}; }, [viewMode, selectedYear, selectedMonth, publicityPeriods]);
const yearWeeks = getYearWeeks(); // Compteurs des cartes : recalculés à chaque rendu car ils dépendent de l'heure courante
const now = new Date();
const activeCount = publicityPeriods.filter(({ start, end }) => start && end && start <= now && now <= end).length;
const upcomingCount = publicityPeriods.filter(({ start }) => start && start > now).length;
const handleView = (publicity: PublicityWithRelations) => { const handleView = (publicity: PublicityWithRelations) => {
setSelectedPublicity(publicity); setSelectedPublicity(publicity);
@@ -405,6 +420,11 @@ export default function Publicities() {
</div> </div>
</div> </div>
{/* Données de l'année précédente affichées atténuées pendant le chargement de la nouvelle année */}
<div
className={`space-y-6 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
aria-busy={isPlaceholderData}
>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <Card>
@@ -429,14 +449,7 @@ export default function Publicities() {
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">En cours</p> <p className="text-sm text-gray-600">En cours</p>
<p className="text-2xl font-semibold"> <p className="text-2xl font-semibold">{activeCount}</p>
{publicities.filter(p => {
const now = new Date();
const start = safeDate(p.startDate);
const end = safeDate(p.endDate);
return start && end && start <= now && now <= end;
}).length}
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -450,12 +463,7 @@ export default function Publicities() {
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">À venir</p> <p className="text-sm text-gray-600">À venir</p>
<p className="text-2xl font-semibold"> <p className="text-2xl font-semibold">{upcomingCount}</p>
{publicities.filter(p => {
const startDate = safeDate(p.startDate);
return startDate && startDate > new Date();
}).length}
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -477,7 +485,7 @@ export default function Publicities() {
<div className="grid grid-cols-12 gap-2"> <div className="grid grid-cols-12 gap-2">
{Array.from({ length: 12 }, (_, monthIndex) => { {Array.from({ length: 12 }, (_, monthIndex) => {
const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr }); const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr });
const monthWeeks = yearWeeks.filter(week => week.month === monthIndex); const monthWeeks = weeksByMonth[monthIndex];
return ( return (
<div key={monthIndex} className="space-y-2"> <div key={monthIndex} className="space-y-2">
@@ -611,29 +619,7 @@ export default function Publicities() {
))} ))}
{/* Calendar days */} {/* Calendar days */}
{(() => { {calendarDays.map(({ day, isCurrentMonth, dayPublicities, participatingStores }) => {
const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1));
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map(day => {
const isCurrentMonth = isSameMonth(day, monthStart);
const dayPublicities = publicities.filter(pub => {
const pubStart = safeDate(pub.startDate);
const pubEnd = safeDate(pub.endDate);
return pubStart && pubEnd && day >= pubStart && day <= pubEnd;
});
const participatingStores = new Set<number>();
dayPublicities.forEach(pub => {
pub.participations.forEach(participation => {
participatingStores.add(participation.groupId);
});
});
return ( return (
<div <div
key={day.toISOString()} key={day.toISOString()}
@@ -665,10 +651,10 @@ export default function Publicities() {
</div> </div>
)} )}
{/* Store participation indicators */} {/* Store participation indicators */}
{participatingStores.size > 0 && ( {participatingStores.length > 0 && (
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
{Array.from(participatingStores).slice(0, 3).map(storeId => { {participatingStores.slice(0, 3).map(storeId => {
const group = groups.find(g => g.id === storeId); const group = groupsById.get(storeId);
return ( return (
<div <div
key={storeId} key={storeId}
@@ -678,15 +664,14 @@ export default function Publicities() {
/> />
); );
})} })}
{participatingStores.size > 3 && ( {participatingStores.length > 3 && (
<div className="text-xs text-gray-500">+{participatingStores.size - 3}</div> <div className="text-xs text-gray-500">+{participatingStores.length - 3}</div>
)} )}
</div> </div>
)} )}
</div> </div>
); );
}); })}
})()}
</div> </div>
{/* Legend */} {/* Legend */}
@@ -851,6 +836,7 @@ export default function Publicities() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div>
{/* Create Modal */} {/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}> <Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
+35 -56
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -49,6 +49,10 @@ const priorityConfig = {
critique: { label: "Critique", color: "bg-red-100 text-red-800" } critique: { label: "Critique", color: "bg-red-100 text-red-800" }
}; };
// Références stables tant que les données ne sont pas chargées (évitent de recalculer les filtres)
const NO_TICKETS: SavTicketWithRelations[] = [];
const NO_GROUPS: Group[] = [];
export default function SavTickets() { export default function SavTickets() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { toast } = useToast(); const { toast } = useToast();
@@ -94,10 +98,15 @@ export default function SavTickets() {
const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`; const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const { data: ticketsData = [], isLoading } = useQuery<SavTicketWithRelations[]>({ const { data: ticketsData = NO_TICKETS, isLoading, isPlaceholderData } = useQuery<SavTicketWithRelations[]>({
queryKey: [ticketsUrl, selectedStoreId], queryKey: [ticketsUrl, selectedStoreId],
enabled: !!user && (user.role !== 'admin' || storeInitialized), enabled: !!user && (user.role !== 'admin' || storeInitialized),
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
// Changement de filtre : les tickets précédents restent affichés (estompés)
// pendant le chargement. Jamais au changement de magasin, pour ne pas montrer
// un instant les tickets d'un autre magasin.
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
}); });
const { data: suppliersData = [] } = useQuery<Supplier[]>({ const { data: suppliersData = [] } = useQuery<Supplier[]>({
@@ -105,7 +114,7 @@ export default function SavTickets() {
staleTime: 1000 * 60 * 10, // 10 minutes staleTime: 1000 * 60 * 10, // 10 minutes
}); });
const { data: groupsData = [] } = useQuery<Group[]>({ const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
staleTime: 1000 * 60 * 10, // 10 minutes staleTime: 1000 * 60 * 10, // 10 minutes
}); });
@@ -288,30 +297,32 @@ export default function SavTickets() {
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || ''); const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
const canDelete = ['admin', 'directeur'].includes(user?.role || ''); const canDelete = ['admin', 'directeur'].includes(user?.role || '');
// Get user's available groups for auto-assignment // Get user's available groups for auto-assignment (recalculé seulement quand
const getUserGroups = () => { // l'utilisateur ou la liste des magasins change, et non à chaque frappe)
const availableGroups = useMemo(() => {
if (!user || user.role === 'admin') { if (!user || user.role === 'admin') {
console.log('🎫 [CLIENT] Admin user - can access all groups:', groupsData?.length || 0);
return groupsData; // Admin can access all groups return groupsData; // Admin can access all groups
} }
// For other users, get their assigned groups // For other users, get their assigned groups
const userGroups = (user as any).userGroups || []; const userGroups = (user as any).userGroups || [];
const filteredGroups = groupsData.filter(group => return groupsData.filter(group =>
userGroups.some((ug: any) => ug.groupId === group.id) userGroups.some((ug: any) => ug.groupId === group.id)
); );
}, [user, groupsData]);
console.log('🎫 [CLIENT] Non-admin user groups:', { // Filter tickets by search term
userRole: user.role, const filteredTickets = useMemo(() => {
userUsername: user.username, if (!searchTerm) return ticketsData;
allUserGroups: userGroups, const searchLower = searchTerm.toLowerCase();
filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name })) return ticketsData.filter(ticket => (
}); ticket.ticketNumber.toLowerCase().includes(searchLower) ||
ticket.clientName?.toLowerCase().includes(searchLower) ||
return filteredGroups; ticket.problemDescription?.toLowerCase().includes(searchLower) ||
}; ticket.productDesignation?.toLowerCase().includes(searchLower) ||
ticket.supplier?.name.toLowerCase().includes(searchLower)
const availableGroups = getUserGroups(); ));
}, [ticketsData, searchTerm]);
// Handle form submission // Handle form submission
const handleCreateTicket = () => { const handleCreateTicket = () => {
@@ -327,17 +338,9 @@ export default function SavTickets() {
// Auto-assign group based on user role and available groups // Auto-assign group based on user role and available groups
let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null; let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null;
console.log('🎫 [CLIENT] User groups analysis:', {
userRole: user?.role,
formDataGroupId: formData.groupId,
availableGroups: availableGroups.map(g => ({ id: g.id, name: g.name })),
selectedGroupId
});
if (!selectedGroupId && availableGroups.length > 0) { if (!selectedGroupId && availableGroups.length > 0) {
// Auto-assign first available group // Auto-assign first available group
selectedGroupId = availableGroups[0].id; selectedGroupId = availableGroups[0].id;
console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name);
} }
const ticketData: InsertSavTicket = { const ticketData: InsertSavTicket = {
@@ -355,14 +358,6 @@ export default function SavTickets() {
clientPhone: formData.clientPhone || undefined, clientPhone: formData.clientPhone || undefined,
}; };
console.log('🎫 [CLIENT] Creating ticket with data:', {
...ticketData,
userInfo: {
role: user?.role,
username: user?.username,
availableGroups: availableGroups.length
}
});
createTicketMutation.mutate(ticketData); createTicketMutation.mutate(ticketData);
}; };
@@ -372,13 +367,6 @@ export default function SavTickets() {
setShowDetailModal(true); setShowDetailModal(true);
}; };
// Query pour récupérer les détails complets du ticket avec historique
const { data: ticketDetails } = useQuery({
queryKey: [`/api/sav/tickets/${selectedTicket?.id}`],
enabled: !!selectedTicket && showDetailModal,
staleTime: 0, // Toujours récupérer les derniers commentaires
});
// Function to check if ticket has recent comments (last 24h) // Function to check if ticket has recent comments (last 24h)
const hasRecentComments = (ticket: SavTicketWithRelations) => { const hasRecentComments = (ticket: SavTicketWithRelations) => {
if (!ticket.history || ticket.history.length === 0) return false; if (!ticket.history || ticket.history.length === 0) return false;
@@ -441,19 +429,6 @@ export default function SavTickets() {
); );
} }
// Filter tickets by search term
const filteredTickets = ticketsData.filter(ticket => {
if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase();
return (
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
ticket.clientName?.toLowerCase().includes(searchLower) ||
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
ticket.supplier?.name.toLowerCase().includes(searchLower)
);
});
return ( return (
<div className="p-3 sm:p-6 space-y-4 sm:space-y-6"> <div className="p-3 sm:p-6 space-y-4 sm:space-y-6">
{/* Header */} {/* Header */}
@@ -823,7 +798,11 @@ export default function SavTickets() {
Liste des tickets de service après-vente Liste des tickets de service après-vente
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> {/* Estompé pendant le chargement d'un nouveau filtre (anciens résultats affichés) */}
<CardContent
className={`p-0 ${isPlaceholderData ? 'opacity-60 transition-opacity' : ''}`}
aria-busy={isPlaceholderData}
>
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+30 -29
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -29,7 +29,11 @@ import {
CheckCircle, CheckCircle,
Clock Clock
} from "lucide-react"; } from "lucide-react";
import type { Supplier } from "@shared/schema"; import type { EntityActivityStats, Supplier } from "@shared/schema";
// Références stables tant que les données ne sont pas chargées (dépendances des useMemo)
const NO_SUPPLIERS: Supplier[] = [];
const NO_STATS: EntityActivityStats[] = [];
export default function Suppliers() { export default function Suppliers() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
@@ -52,24 +56,19 @@ export default function Suppliers() {
paymentMethod: "", paymentMethod: "",
}); });
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({ const { data: suppliers = NO_SUPPLIERS, isLoading } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
}); });
const { data: orders = [] } = useQuery({ // Comptages agrégés en base : la page n'affiche que des totaux, inutile de
queryKey: ['/api/orders'], // rapatrier l'historique complet des commandes et des livraisons.
}); const { data: supplierStats = NO_STATS } = useQuery<EntityActivityStats[]>({
queryKey: ['/api/stats/by-supplier'],
const { data: deliveries = [] } = useQuery({
queryKey: ['/api/deliveries'],
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
console.log('🚚 Frontend: Creating supplier with data:', data); return await apiRequest("/api/suppliers", "POST", data);
const result = await apiRequest("/api/suppliers", "POST", data);
console.log('🚚 Frontend: Supplier creation result:', result);
return result;
}, },
onSuccess: () => { onSuccess: () => {
toast({ toast({
@@ -102,14 +101,10 @@ export default function Suppliers() {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
console.log('🔧 Frontend: Updating supplier with data:', data); return await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
const result = await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
console.log('🔧 Frontend: Supplier update result:', result);
return result;
}, },
onMutate: async (newData) => { onMutate: async (newData) => {
// Mise à jour optimiste - mettre à jour l'interface immédiatement // Mise à jour optimiste - mettre à jour l'interface immédiatement
console.log('🚀 Frontend: Optimistic update starting...');
await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] }); await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] });
const previousSuppliers = queryClient.getQueryData(['/api/suppliers']); const previousSuppliers = queryClient.getQueryData(['/api/suppliers']);
@@ -121,14 +116,12 @@ export default function Suppliers() {
: supplier : supplier
); );
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers); queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
console.log('✅ Frontend: Optimistic update applied');
} }
return { previousSuppliers }; return { previousSuppliers };
}, },
onError: (error, newData, context) => { onError: (error, newData, context) => {
// Rollback en cas d'erreur // Rollback en cas d'erreur
console.log('⚠️ Frontend: Rolling back optimistic update due to error');
if (context?.previousSuppliers) { if (context?.previousSuppliers) {
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers); queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
} }
@@ -155,13 +148,15 @@ export default function Suppliers() {
title: "Succès", title: "Succès",
description: "Fournisseur modifié avec succès", description: "Fournisseur modifié avec succès",
}); });
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
setShowEditModal(false); setShowEditModal(false);
setSelectedSupplier(null); setSelectedSupplier(null);
resetForm(); resetForm();
}, },
// Une seule invalidation, après succès comme après échec
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] }); queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
// Le mode de paiement est affiché dans l'échéancier (cache de 5 min)
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
}, },
}); });
@@ -275,19 +270,25 @@ export default function Suppliers() {
} }
}; };
const filteredSuppliers = suppliers.filter(supplier => // Filtre recalculé seulement quand la liste ou la recherche change, terme normalisé une seule fois
supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) const filteredSuppliers = useMemo(() => {
const term = searchTerm.toLowerCase();
return suppliers.filter(supplier => supplier.name.toLowerCase().includes(term));
}, [suppliers, searchTerm]);
// Statistiques indexées par fournisseur (au lieu d'une recherche par carte)
const statsBySupplierId = useMemo(
() => new Map(Array.isArray(supplierStats) ? supplierStats.map(s => [s.id, s]) : []),
[supplierStats]
); );
const getSupplierStats = (supplierId: number) => { const getSupplierStats = (supplierId: number) => {
const supplierOrders = orders.filter((order: any) => order.supplierId === supplierId); const stats = statsBySupplierId.get(supplierId);
const supplierDeliveries = deliveries.filter((delivery: any) => delivery.supplierId === supplierId);
const deliveredCount = supplierDeliveries.filter((delivery: any) => delivery.status === 'delivered').length;
return { return {
orders: supplierOrders.length, orders: stats?.orders ?? 0,
deliveries: supplierDeliveries.length, deliveries: stats?.deliveries ?? 0,
delivered: deliveredCount, delivered: stats?.delivered ?? 0,
}; };
}; };
+158 -137
View File
@@ -1,15 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useMemo, useDeferredValue } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useScreenSize, type ScreenBreakpoints } from "@/hooks/use-screen-size";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Pagination, usePagination } from "@/components/ui/pagination"; import { Pagination, usePagination } from "@/components/ui/pagination";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
ListTodo, ListTodo,
@@ -42,9 +42,17 @@ type TaskWithRelations = Task & {
isFutureTask?: boolean; // Pour les tâches futures (admin/directeur uniquement) isFutureTask?: boolean; // Pour les tâches futures (admin/directeur uniquement)
}; };
// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres)
const NO_TASKS: TaskWithRelations[] = [];
// Seuils alignés sur le point de rupture « lg » de Tailwind (1024 px) qui séparait
// les deux mises en page jusque-là masquées en CSS : bureau à partir de 1024 px
const TASKS_LAYOUT_BREAKPOINTS: ScreenBreakpoints = { mobile: 768, tablet: 1023, desktop: 1280 };
// Composant TaskForm inline - Version production ultra-simple // Composant TaskForm inline - Version production ultra-simple
function TaskFormInline({ task, onClose, selectedStoreId, user }: any) { function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -91,22 +99,12 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
(taskData as any).createdBy = user?.username || 'admin'; (taskData as any).createdBy = user?.username || 'admin';
} }
console.log('📤 Tasks.tsx - Sending request:', {
url,
method,
taskData,
selectedStoreId,
finalGroupId: (taskData as any).groupId
});
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData), body: JSON.stringify(taskData),
}); });
console.log('📥 Response:', { status: response.status, ok: response.ok });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('❌ API Error:', { status: response.status, error: errorText }); console.error('❌ API Error:', { status: response.status, error: errorText });
@@ -117,7 +115,10 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
title: "Succès", title: "Succès",
description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès", description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès",
}); });
window.location.reload(); // Rechargement ciblé de la liste des tâches (plus de rechargement complet de
// la page) : filtres, recherche et pagination sont conservés
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
onClose();
} catch (error) { } catch (error) {
console.error('Erreur:', error); console.error('Erreur:', error);
@@ -277,35 +278,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
export default function Tasks() { export default function Tasks() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId, storeInitialized } = useStore(); const { selectedStoreId } = useStore();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Une seule des deux mises en page (tablette ou bureau) est montée
// Debug enablement condition for tasks query const { isDesktop: isLargeScreen } = useScreenSize(TASKS_LAYOUT_BREAKPOINTS);
const isQueryEnabled = !!user && (user.role === 'admin' || (user.role === 'directeur' || user.role === 'manager' ? !!selectedStoreId : true));
console.log('🔍 TASK QUERY ENABLEMENT DEBUG:', {
hasUser: !!user,
userRole: user?.role,
selectedStoreId,
isAdmin: user?.role === 'admin',
isDirecteurOrManager: user?.role === 'directeur' || user?.role === 'manager',
hasSelectedStore: !!selectedStoreId,
finalEnabled: isQueryEnabled,
storeInitialized,
timestamp: new Date().toISOString()
});
// Force refresh when selectedStoreId changes for directeur/manager
useEffect(() => {
if (user && (user.role === 'directeur' || user.role === 'manager') && selectedStoreId) {
console.log('🔄 FORCE REFRESH: selectedStoreId changed for directeur/manager:', {
userRole: user.role,
selectedStoreId,
timestamp: new Date().toISOString()
});
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
}
}, [selectedStoreId, user, queryClient]);
// États locaux // États locaux
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -322,9 +299,15 @@ export default function Tasks() {
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null); const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null); const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null);
// Fetch tasks - attendre que l'initialisation des stores soit terminée pour les admins // Clé propre à cette page : le tableau de bord utilise ['/api/tasks', storeId]
const { data: tasks = [], isLoading, error } = useQuery({ // avec une URL différente pour les non-admins (sans storeId), sa réponse ne doit
queryKey: ["/api/tasks", selectedStoreId], // donc jamais être réutilisée ici. Le changement de magasin change la clé, ce
// qui suffit à recharger la liste (plus d'invalidation forcée au montage).
const tasksQueryKey = ["/api/tasks", { storeId: selectedStoreId }];
// Fetch tasks
const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({
queryKey: tasksQueryKey,
queryFn: async () => { queryFn: async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -332,16 +315,6 @@ export default function Tasks() {
if (selectedStoreId) { if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString()); params.append('storeId', selectedStoreId.toString());
} }
console.log('📋 TASKS QUERY - Fetching with params:', {
selectedStoreId,
userRole: user?.role,
storeInitialized,
url: `/api/tasks?${params.toString()}`,
willFilterByStore: !!selectedStoreId,
enabled: !!user,
timestamp: new Date().toISOString(),
userGroups: user?.userGroups?.map((ug: any) => ug.groupId) || 'NONE'
});
const response = await fetch(`/api/tasks?${params.toString()}`, { const response = await fetch(`/api/tasks?${params.toString()}`, {
credentials: 'include' credentials: 'include'
@@ -353,18 +326,6 @@ export default function Tasks() {
const data = await response.json(); const data = await response.json();
console.log('📋 TASKS QUERY - Response received:', {
dataType: typeof data,
isArray: Array.isArray(data),
length: data?.length,
firstTask: data?.[0] ? {
id: data[0].id,
title: data[0].title,
hasStartDate: !!data[0].startDate,
hasGroup: !!data[0].group
} : null
});
// Valider et nettoyer les données reçues // Valider et nettoyer les données reçues
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
console.error('Tasks API returned non-array data:', data); console.error('Tasks API returned non-array data:', data);
@@ -379,31 +340,33 @@ export default function Tasks() {
} }
}, },
enabled: !!user, // Charger les données dès que l'utilisateur est connecté enabled: !!user, // Charger les données dès que l'utilisateur est connecté
// Au changement de magasin, la liste précédente reste affichée (estompée et
// non cliquable, avec un indicateur) au lieu d'un spinner plein écran
placeholderData: keepPreviousData,
}); });
// Fetch users for task assignment - seulement pour admin/manager/directeur // Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
const { data: users = [] } = useQuery({ // pouvoir le restaurer si le serveur refuse l'opération
queryKey: ["/api/users"], const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
queryFn: () => fetch('/api/users', { const queryKey = tasksQueryKey;
credentials: 'include' await queryClient.cancelQueries({ queryKey });
}).then(res => { const previousTasks = queryClient.getQueryData<TaskWithRelations[]>(queryKey);
if (!res.ok) { if (Array.isArray(previousTasks)) {
throw new Error(`HTTP error! status: ${res.status}`); queryClient.setQueryData<TaskWithRelations[]>(queryKey, update(previousTasks));
} }
return res.json(); return { queryKey, previousTasks };
}),
enabled: !!user && (user.role === 'admin' || user.role === 'manager' || user.role === 'directeur'),
});
const handleEditTask = (task: TaskWithRelations) => {
setSelectedTask(task);
setShowEditModal(true);
}; };
const handleCompleteTask = async (taskId: number) => { const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => {
try { if (context?.previousTasks) {
queryClient.setQueryData(context.queryKey, context.previousTasks);
}
};
// Terminer : la tâche passe tout de suite dans les tâches terminées, puis la
// liste est rechargée depuis le serveur
const completeTaskMutation = useMutation({
mutationFn: async (taskId: number) => {
const response = await fetch(`/api/tasks/${taskId}/complete`, { const response = await fetch(`/api/tasks/${taskId}/complete`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -416,32 +379,39 @@ export default function Tasks() {
if (!response.ok) { if (!response.ok) {
throw new Error('Erreur lors de la completion de la tâche'); throw new Error('Erreur lors de la completion de la tâche');
} }
},
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); onMutate: (taskId: number) => {
const now = new Date();
return updateCachedTasks((list) => list.map((task) =>
task.id === taskId
? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now }
: task
));
},
onSuccess: () => {
toast({ toast({
title: "Succès", title: "Succès",
description: "Tâche marquée comme terminée", description: "Tâche marquée comme terminée",
}); });
} catch (error) { },
onError: (error, _taskId, context) => {
restoreCachedTasks(context);
console.error("Error completing task:", error); console.error("Error completing task:", error);
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de terminer la tâche", description: "Impossible de terminer la tâche",
variant: "destructive", variant: "destructive",
}); });
} },
}; onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
},
});
const handleDeleteClick = (task: TaskWithRelations) => { // Supprimer : la tâche disparaît tout de suite de la liste et la modale se ferme
setTaskToDelete(task); const deleteTaskMutation = useMutation({
setShowDeleteModal(true); mutationFn: async (taskId: number) => {
}; const response = await fetch(`/api/tasks/${taskId}`, {
const handleConfirmDelete = async () => {
if (!taskToDelete) return;
try {
const response = await fetch(`/api/tasks/${taskToDelete.id}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
}); });
@@ -449,31 +419,61 @@ export default function Tasks() {
if (!response.ok) { if (!response.ok) {
throw new Error('Erreur lors de la suppression'); throw new Error('Erreur lors de la suppression');
} }
},
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)),
onSuccess: () => {
toast({ toast({
title: "Succès", title: "Succès",
description: "Tâche supprimée avec succès", description: "Tâche supprimée avec succès",
}); });
},
setShowDeleteModal(false); onError: (error, _taskId, context) => {
setTaskToDelete(null); restoreCachedTasks(context);
} catch (error) {
console.error("Error deleting task:", error); console.error("Error deleting task:", error);
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de supprimer la tâche", description: "Impossible de supprimer la tâche",
variant: "destructive", variant: "destructive",
}); });
} },
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
},
});
const handleEditTask = (task: TaskWithRelations) => {
setSelectedTask(task);
setShowEditModal(true);
}; };
// Filtrer et trier les tâches const handleCompleteTask = (taskId: number) => {
const filteredTasks = tasks completeTaskMutation.mutate(taskId);
};
const handleDeleteClick = (task: TaskWithRelations) => {
setTaskToDelete(task);
setShowDeleteModal(true);
};
const handleConfirmDelete = () => {
if (!taskToDelete) return;
deleteTaskMutation.mutate(taskToDelete.id);
setShowDeleteModal(false);
setTaskToDelete(null);
};
// La recherche est différée : la saisie reste fluide pendant le filtrage
const deferredSearchTerm = useDeferredValue(searchTerm);
// Filtrer et trier les tâches (recalculé seulement quand la liste ou un filtre change)
const filteredTasks = useMemo(() => {
const search = deferredSearchTerm.toLowerCase();
return tasks
.filter((task: TaskWithRelations) => { .filter((task: TaskWithRelations) => {
// Filtre par recherche // Filtre par recherche
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) && if (search && !task.title.toLowerCase().includes(search) &&
!task.description?.toLowerCase().includes(searchTerm.toLowerCase())) { !task.description?.toLowerCase().includes(search)) {
return false; return false;
} }
@@ -529,6 +529,7 @@ export default function Tasks() {
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0; const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return bDate - aDate; return bDate - aDate;
}); });
}, [tasks, deferredSearchTerm, statusFilter, priorityFilter, dueDateFilter]);
// Pagination // Pagination
const { const {
@@ -541,6 +542,18 @@ export default function Tasks() {
totalItems totalItems
} = usePagination(filteredTasks, 10); } = usePagination(filteredTasks, 10);
// Sous-listes calculées une seule fois par rendu (page courante et Kanban)
const pagePendingTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'pending');
const pageCompletedTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'completed');
const { kanbanPendingTasks, kanbanCompletedTasks } = useMemo(() => ({
kanbanPendingTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending'),
kanbanCompletedTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed'),
}), [filteredTasks]);
// Liste du magasin précédent affichée pendant le chargement du nouveau magasin :
// estompée et non cliquable pour ne pas agir sur une tâche de l'ancien magasin
const listBusyClass = isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : "";
const getPriorityConfig = (priority: string) => { const getPriorityConfig = (priority: string) => {
switch (priority) { switch (priority) {
case 'high': case 'high':
@@ -687,6 +700,12 @@ export default function Tasks() {
</h2> </h2>
<p className="text-gray-600 mt-1 text-sm sm:text-base"> <p className="text-gray-600 mt-1 text-sm sm:text-base">
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''} {totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</p> </p>
</div> </div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
@@ -757,8 +776,9 @@ export default function Tasks() {
</div> </div>
</div> </div>
{/* Section filtres - Responsive */} {/* Section filtres - Responsive (tablette uniquement) */}
<div className={`${filtersOpen ? 'block' : 'hidden'} lg:hidden mb-4`}> {!isLargeScreen && (
<div className={`${filtersOpen ? 'block' : 'hidden'} mb-4`}>
<Card className="mb-4 sm:mb-6"> <Card className="mb-4 sm:mb-6">
<CardHeader className="pb-3 sm:pb-4"> <CardHeader className="pb-3 sm:pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -846,9 +866,11 @@ export default function Tasks() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
{/* Zone principale mobile/tablet - Contenu pleine largeur */} {/* Zone principale mobile/tablet - Contenu pleine largeur */}
<div className="block lg:hidden"> {!isLargeScreen && (
<div className={listBusyClass} aria-busy={isPlaceholderData}>
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full"> <Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
<TabsContent value="list" className="mt-0"> <TabsContent value="list" className="mt-0">
{/* Contenu mobile identique au desktop mais optimisé */} {/* Contenu mobile identique au desktop mais optimisé */}
@@ -865,14 +887,13 @@ export default function Tasks() {
) : ( ) : (
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
{/* Tâches en cours - Mobile optimisé */} {/* Tâches en cours - Mobile optimisé */}
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && ( {pagePendingTasks.length > 0 && (
<div> <div>
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 px-1"> <h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 px-1">
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length}) Tâches en cours ({pagePendingTasks.length})
</h4> </h4>
<div className="space-y-2 sm:space-y-3"> <div className="space-y-2 sm:space-y-3">
{paginatedTasks {pagePendingTasks
.filter(task => task.status === 'pending')
.map((task) => { .map((task) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@@ -1024,14 +1045,13 @@ export default function Tasks() {
)} )}
{/* Tâches terminées - Mobile optimisé */} {/* Tâches terminées - Mobile optimisé */}
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && ( {pageCompletedTasks.length > 0 && (
<div> <div>
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 mt-6 px-1"> <h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 mt-6 px-1">
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length}) Tâches terminées ({pageCompletedTasks.length})
</h4> </h4>
<div className="space-y-2 sm:space-y-3"> <div className="space-y-2 sm:space-y-3">
{paginatedTasks {pageCompletedTasks
.filter(task => task.status === 'completed')
.map((task) => { .map((task) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@@ -1088,9 +1108,11 @@ export default function Tasks() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
)}
{/* Sidebar desktop uniquement */} {/* Sidebar desktop uniquement */}
<div className="hidden lg:flex gap-6"> {isLargeScreen && (
<div className="flex gap-6">
{/* Sidebar avec filtres desktop */} {/* Sidebar avec filtres desktop */}
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0"> <div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
<Card> <Card>
@@ -1167,7 +1189,7 @@ export default function Tasks() {
</div> </div>
{/* Zone principale avec les tâches - Desktop */} {/* Zone principale avec les tâches - Desktop */}
<div className="flex-1"> <div className={`flex-1 ${listBusyClass}`} aria-busy={isPlaceholderData}>
{/* Contenu selon la vue sélectionnée */} {/* Contenu selon la vue sélectionnée */}
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full"> <Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
<TabsContent value="list" className="mt-0"> <TabsContent value="list" className="mt-0">
@@ -1186,14 +1208,13 @@ export default function Tasks() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Tâches en cours */} {/* Tâches en cours */}
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && ( {pagePendingTasks.length > 0 && (
<div> <div>
<h4 className="text-lg font-semibold text-gray-900 mb-3"> <h4 className="text-lg font-semibold text-gray-900 mb-3">
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length}) Tâches en cours ({pagePendingTasks.length})
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{paginatedTasks {pagePendingTasks
.filter(task => task.status === 'pending')
.map((task) => { .map((task) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@@ -1334,14 +1355,13 @@ export default function Tasks() {
)} )}
{/* Tâches terminées */} {/* Tâches terminées */}
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && ( {pageCompletedTasks.length > 0 && (
<div> <div>
<h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8"> <h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8">
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length}) Tâches terminées ({pageCompletedTasks.length})
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{paginatedTasks {pageCompletedTasks
.filter(task => task.status === 'completed')
.map((task) => { .map((task) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@@ -1426,10 +1446,10 @@ export default function Tasks() {
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Circle className="w-5 h-5 text-yellow-500" /> <Circle className="w-5 h-5 text-yellow-500" />
En cours ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').length}) En cours ({kanbanPendingTasks.length})
</h3> </h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto"> <div className="space-y-3 max-h-[600px] overflow-y-auto">
{filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').map((task: TaskWithRelations) => { {kanbanPendingTasks.map((task: TaskWithRelations) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
const dueDateStatus = getDueDateStatus(task.dueDate, task.status); const dueDateStatus = getDueDateStatus(task.dueDate, task.status);
@@ -1506,10 +1526,10 @@ export default function Tasks() {
<div className="bg-green-50 rounded-lg p-4"> <div className="bg-green-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" /> <CheckCircle className="w-5 h-5 text-green-500" />
Terminées ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').length}) Terminées ({kanbanCompletedTasks.length})
</h3> </h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto"> <div className="space-y-3 max-h-[600px] overflow-y-auto">
{filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').map((task: TaskWithRelations) => { {kanbanCompletedTasks.map((task: TaskWithRelations) => {
const priorityConfig = getPriorityConfig(task.priority); const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@@ -1562,6 +1582,7 @@ export default function Tasks() {
</Tabs> </Tabs>
</div> </div>
</div> </div>
)}
{/* Modal d'édition */} {/* Modal d'édition */}
{showEditModal && selectedTask && ( {showEditModal && selectedTask && (
-568
View File
@@ -1,568 +0,0 @@
import { useState, useEffect } from "react";
// Interface pour les tâches
interface Task {
id: number;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
status: 'pending' | 'completed';
assignedTo: string;
startDate?: string | null;
dueDate?: string | null;
createdAt?: string;
groupId?: number;
createdBy?: string;
}
export default function TasksListSimple() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreateForm, setShowCreateForm] = useState(false);
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
} else {
console.error('Erreur lors du chargement des tâches:', response.status);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Créer une tâche
const createTask = async (formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
groupId: 1,
createdBy: 'admin',
};
console.log('📤 Creating task:', taskData);
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Create Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche créée avec succès");
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la création:', error);
alert("Erreur lors de la création");
return false;
}
};
// Modifier une tâche
const updateTask = async (id: number, formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
// Préparer les données exactement comme le serveur les attend
const taskData: Partial<Task> = {
title: title.trim(),
description: description?.trim() || "",
priority: (priority as Task['priority']) || "medium",
status: (status as Task['status']) || "pending",
assignedTo: assignedTo.trim(),
};
// Gestion spéciale des dates pour éviter les erreurs de format
if (startDate && startDate.trim() !== '') {
taskData.startDate = startDate;
} else {
taskData.startDate = null;
}
if (dueDate && dueDate.trim() !== '') {
taskData.dueDate = dueDate;
} else {
taskData.dueDate = null;
}
console.log('📤 Updating task:', { id, taskData });
const response = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Update Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Update Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche modifiée avec succès");
setEditingId(null);
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la modification:', error);
alert("Erreur lors de la modification");
return false;
}
};
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = !searchTerm || task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Formulaire inline
const TaskForm = ({ task, onSubmit, onCancel }: {
task?: Task;
onSubmit: (formData: FormData) => Promise<boolean>;
onCancel: () => void;
}) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const success = await onSubmit(formData);
if (success && !task) {
(e.target as HTMLFormElement).reset();
}
};
return (
<tr style={{ backgroundColor: '#f9fafb' }}>
<td colSpan={6} style={{ padding: '16px' }}>
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Description
</label>
<input
name="description"
type="text"
defaultValue={task?.description || ""}
placeholder="Description (optionnel)"
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Priorité
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
📅 Date de début
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
⏰ Date d'échéance
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '6px 12px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</td>
</tr>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '8px 16px',
backgroundColor: showCreateForm ? '#dc2626' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
{showCreateForm ? '❌ Annuler' : '➕ Nouvelle Tâche'}
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Tableau des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ backgroundColor: '#f9fafb' }}>
<tr>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Titre</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Priorité</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Statut</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Assigné à</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Dates</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Actions</th>
</tr>
</thead>
<tbody>
{showCreateForm && (
<TaskForm
onSubmit={createTask}
onCancel={() => setShowCreateForm(false)}
/>
)}
{filteredTasks.length === 0 ? (
<tr>
<td colSpan={6} style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
Aucune tâche trouvée
</td>
</tr>
) : (
filteredTasks.map((task) => (
editingId === task.id ? (
<TaskForm
key={task.id}
task={task}
onSubmit={(formData) => updateTask(task.id, formData)}
onCancel={() => setEditingId(null)}
/>
) : (
<tr key={task.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px 16px' }}>
<div>
<div style={{
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</div>
{task.description && (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
{task.description}
</div>
)}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.priority === 'high' ? '#fee2e2' : task.priority === 'medium' ? '#fef3c7' : '#d1fae5',
color: task.priority === 'high' ? '#dc2626' : task.priority === 'medium' ? '#d97706' : '#059669'
}}>
{task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.status === 'completed' ? '#d1fae5' : '#fef3c7',
color: task.status === 'completed' ? '#059669' : '#d97706'
}}>
{task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#6b7280' }}>
{task.assignedTo}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#6b7280' }}>
{task.startDate && (
<div>📅 {new Date(task.startDate).toLocaleDateString('fr-FR')}</div>
)}
{task.dueDate && (
<div>⏰ {new Date(task.dueDate).toLocaleDateString('fr-FR')}</div>
)}
</td>
<td style={{ padding: '12px 16px' }}>
<button
onClick={() => setEditingId(task.id)}
style={{
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</td>
</tr>
)
))
)}
</tbody>
</table>
</div>
</div>
);
}
-546
View File
@@ -1,546 +0,0 @@
import { useState, useEffect } from "react";
// Version 100% autonome sans aucune dépendance externe
export default function TasksProductionSimple() {
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Composant formulaire inline
const TaskForm = ({ task, onClose }: any) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return;
}
const submitBtn = e.currentTarget.querySelector('button[type="submit"]') as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "...";
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
};
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
const method = task ? 'PUT' : 'POST';
if (!task) {
(taskData as any).groupId = 1;
(taskData as any).createdBy = 'admin';
}
console.log('📤 Sending request:', { url, method, taskData });
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ API Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert(task ? "Tâche modifiée avec succès" : "Tâche créée avec succès");
onClose();
loadTasks(); // Recharger les tâches
} catch (error) {
console.error('Erreur:', error);
alert("Erreur lors de l'opération");
submitBtn.disabled = false;
submitBtn.textContent = task ? "Modifier" : "Créer";
}
};
return (
<div style={{ padding: '24px', backgroundColor: 'white' }}>
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '16px', marginBottom: '24px' }}>
<p style={{ fontSize: '14px', color: '#6b7280', margin: '0' }}>
📅 Date de début = Quand la tâche devient visible<br/>
⏰ Date d'échéance = Quand la tâche doit être terminée
</p>
<button
type="button"
onClick={onClose}
style={{
float: 'right',
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Description
</label>
<textarea
name="description"
defaultValue={task?.description || ""}
placeholder="Description de la tâche (optionnel)"
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Priorité *
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
📅 Date de début <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
⏰ Date d'échéance <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne assignée"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</div>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '24px',
marginBottom: '24px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateModal(true)}
style={{
padding: '10px 20px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
➕ Nouvelle Tâche
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Liste des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
{filteredTasks.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
<p>Aucune tâche trouvée</p>
</div>
) : (
filteredTasks.map((task, index) => (
<div
key={task.id}
style={{
padding: '16px 24px',
borderBottom: index < filteredTasks.length - 1 ? '1px solid #e5e7eb' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</h3>
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
<span>
Priorité: {task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
<span>
Statut: {task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
<span>Assigné à: {task.assignedTo}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => {
setSelectedTask(task);
setShowEditModal(true);
}}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</div>
</div>
))
)}
</div>
{/* Modal de création */}
{showCreateModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Créer une nouvelle tâche
</h2>
</div>
<TaskForm onClose={() => setShowCreateModal(false)} />
</div>
</div>
)}
{/* Modal d'édition */}
{showEditModal && selectedTask && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Modifier la tâche
</h2>
</div>
<TaskForm
task={selectedTask}
onClose={() => {
setShowEditModal(false);
setSelectedTask(null);
}}
/>
</div>
</div>
)}
</div>
);
}
-504
View File
@@ -1,504 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
ListTodo,
Plus,
Search,
Circle,
Clock,
AlertTriangle,
CheckCircle,
Edit,
Trash2
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Task } from "@shared/schema";
type TaskWithRelations = Task & {
creator: { id: string; username: string; name: string; };
group: { id: number; name: string; color: string; };
};
export default function TasksSimplified() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
// États locaux
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = useState<string>("all");
// Modals et tâche sélectionnée
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
// Récupération des tâches
const { data: tasks = [], isLoading } = useQuery({
queryKey: ["/api/tasks", selectedStoreId],
queryFn: () => {
const params = new URLSearchParams();
if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
},
enabled: !!user,
});
// Récupération des magasins
const { data: stores = [] } = useQuery<any[]>({
queryKey: ["/api/groups"],
enabled: !!user,
});
// Filtrer les tâches
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
// Création d'une tâche
const handleCreateTask = async (taskData: any) => {
try {
const finalData = {
...taskData,
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
};
console.log('🔍 TasksSimplified - Creating task with data:', {
selectedStoreId,
storesLength: stores.length,
finalGroupId: finalData.groupId,
taskData: finalData
});
await apiRequest('/api/tasks', 'POST', finalData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowCreateModal(false);
toast({
title: "Succès",
description: "Tâche créée avec succès",
});
} catch (error) {
console.error('❌ TasksSimplified - Error creating task:', error);
toast({
title: "Erreur",
description: "Impossible de créer la tâche",
variant: "destructive",
});
}
};
// Modification d'une tâche
const handleUpdateTask = async (taskData: any) => {
if (!selectedTask) return;
try {
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowEditModal(false);
setSelectedTask(null);
toast({
title: "Succès",
description: "Tâche modifiée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de modifier la tâche",
variant: "destructive",
});
}
};
// Complétion d'une tâche
const handleCompleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche marquée comme terminée",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de compléter la tâche",
variant: "destructive",
});
}
};
// Suppression d'une tâche
const handleDeleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche supprimée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de supprimer la tâche",
variant: "destructive",
});
}
};
const handleEditTask = (task: TaskWithRelations) => {
setSelectedTask(task);
setShowEditModal(true);
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed": return "bg-green-100 text-green-800";
case "pending": return "bg-yellow-100 text-yellow-800";
default: return "bg-gray-100 text-gray-800";
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case "high": return "bg-red-100 text-red-800";
case "medium": return "bg-blue-100 text-blue-800";
case "low": return "bg-green-100 text-green-800";
default: return "bg-gray-100 text-gray-800";
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Chargement des tâches...</div>
</div>
);
}
if (!user) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<ListTodo className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
</div>
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Tâche
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
</DialogHeader>
<TaskForm onSubmit={handleCreateTask} />
</DialogContent>
</Dialog>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filtres</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher une tâche..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="min-w-[150px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
<SelectItem value="completed">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-[150px]">
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priorité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes priorités</SelectItem>
<SelectItem value="high">Haute</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="low">Basse</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Liste des tâches */}
<div className="space-y-4">
{filteredTasks.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center h-32">
<div className="text-center">
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucune tâche trouvée</p>
</div>
</CardContent>
</Card>
) : (
filteredTasks.map((task: TaskWithRelations) => (
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
<Badge className={getStatusColor(task.status)}>
{task.status === "completed" ? "Terminée" : "En attente"}
</Badge>
<Badge className={getPriorityColor(task.priority)}>
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
</Badge>
</div>
{task.description && (
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
)}
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<span className="font-medium">Assigné à:</span>
<span>{task.assignedTo || "Non assigné"}</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Magasin:</span>
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
{task.group.name}
</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Créé par:</span>
<span>{task.creator.name || task.creator.username}</span>
</div>
{task.dueDate && (
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{task.status !== "completed" && (
<Button
size="sm"
variant="outline"
onClick={() => handleCompleteTask(task.id)}
className="text-green-600 hover:text-green-700"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handleEditTask(task)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteTask(task.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Modal de modification */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier la tâche</DialogTitle>
</DialogHeader>
{selectedTask && (
<TaskForm
initialData={selectedTask}
onSubmit={handleUpdateTask}
isEditing={true}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
// Composant de formulaire simplifié
function TaskForm({ onSubmit, initialData, isEditing = false }: {
onSubmit: (data: any) => void;
initialData?: TaskWithRelations;
isEditing?: boolean;
}) {
const [title, setTitle] = useState(initialData?.title || "");
const [description, setDescription] = useState(initialData?.description || "");
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
const [priority, setPriority] = useState(initialData?.priority || "medium");
const [dueDate, setDueDate] = useState(
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
return;
}
const taskData = {
title: title.trim(),
description: description.trim() || undefined,
assignedTo: assignedTo?.trim() || "Non assigné",
priority,
startDate: null, // Toujours inclure startDate même si null
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
};
onSubmit(taskData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Titre de la tâche *
</label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de la tâche"
className="w-full p-2 border rounded-md"
rows={3}
/>
</div>
<div>
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
Assigné à <span className="text-xs text-gray-500">(optionnel)</span>
</label>
<Input
id="assignedTo"
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
placeholder="Nom de la personne responsable"
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Priorité
</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Basse</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="high">Haute</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
Date d'échéance
</label>
<Input
id="dueDate"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex justify-end space-x-2">
<Button type="submit">
{isEditing ? "Modifier" : "Créer"}
</Button>
</div>
</form>
);
}
+22 -19
View File
@@ -28,7 +28,7 @@ import {
import type { UserWithGroups, Group } from "@shared/schema"; import type { UserWithGroups, Group } from "@shared/schema";
export default function UsersPage() { export default function UsersPage() {
const { user } = useAuthUnified(); const { user, refreshAuth } = useAuthUnified();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -132,12 +132,17 @@ export default function UsersPage() {
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates); const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
return response; return response;
}, },
onSuccess: (updatedUser) => { onSuccess: (updatedUser, variables) => {
toast({ toast({
title: "Succès", title: "Succès",
description: "Utilisateur mis à jour avec succès", description: "Utilisateur mis à jour avec succès",
}); });
// Compte de l'administrateur connecté : rafraîchir l'utilisateur partagé par l'application
if (variables.id === user?.id) {
refreshAuth();
}
// Update the form with the response data before closing // Update the form with the response data before closing
if (updatedUser) { if (updatedUser) {
setEditForm({ setEditForm({
@@ -224,25 +229,24 @@ export default function UsersPage() {
const assignGroupMutation = useMutation({ const assignGroupMutation = useMutation({
mutationFn: async (data: { userId: string; groupId: number }) => { mutationFn: async (data: { userId: string; groupId: number }) => {
console.log('📤 Assigning group:', data);
const response = await apiRequest( const response = await apiRequest(
`/api/users/${data.userId}/groups`, `/api/users/${data.userId}/groups`,
"POST", "POST",
{ groupId: data.groupId } { groupId: data.groupId }
); );
console.log('✅ Group assignment response:', response);
return response; return response;
}, },
onSuccess: () => { onSuccess: (_response, variables) => {
console.log('✅ Group assigned successfully');
toast({ toast({
title: "Succès", title: "Succès",
description: "Utilisateur assigné au groupe avec succès", description: "Utilisateur assigné au groupe avec succès",
}); });
// Invalidation complète du cache // Seule la liste des utilisateurs change : la liste des magasins
// (['/api/groups']) ne dépend pas des affectations
queryClient.invalidateQueries({ queryKey: ['/api/users'] }); queryClient.invalidateQueries({ queryKey: ['/api/users'] });
if (variables.userId === user?.id) {
queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); refreshAuth();
}
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('❌ Error assigning group:', error); console.error('❌ Error assigning group:', error);
@@ -270,24 +274,23 @@ export default function UsersPage() {
const removeGroupMutation = useMutation({ const removeGroupMutation = useMutation({
mutationFn: async (data: { userId: string; groupId: number }) => { mutationFn: async (data: { userId: string; groupId: number }) => {
console.log('🗑️ Removing group:', data);
const response = await apiRequest( const response = await apiRequest(
`/api/users/${data.userId}/groups/${data.groupId}`, `/api/users/${data.userId}/groups/${data.groupId}`,
"DELETE" "DELETE"
); );
console.log('✅ Group removal response:', response);
return response; return response;
}, },
onSuccess: () => { onSuccess: (_response, variables) => {
console.log('✅ Group removed successfully');
toast({ toast({
title: "Succès", title: "Succès",
description: "Utilisateur retiré du groupe avec succès", description: "Utilisateur retiré du groupe avec succès",
}); });
// Invalidation complète du cache // Seule la liste des utilisateurs change : la liste des magasins
// (['/api/groups']) ne dépend pas des affectations
queryClient.invalidateQueries({ queryKey: ['/api/users'] }); queryClient.invalidateQueries({ queryKey: ['/api/users'] });
if (variables.userId === user?.id) {
queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); refreshAuth();
}
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('❌ Error removing group:', error); console.error('❌ Error removing group:', error);
@@ -737,7 +740,7 @@ export default function UsersPage() {
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-2 h-2 rounded-full mr-1" className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: userGroup.group.color }} style={{ backgroundColor: userGroup.group.color || undefined }}
/> />
{userGroup.group.name} {userGroup.group.name}
</div> </div>
@@ -901,7 +904,7 @@ export default function UsersPage() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
/> />
<span className="text-sm">{group.name}</span> <span className="text-sm">{group.name}</span>
</div> </div>
@@ -1063,7 +1066,7 @@ export default function UsersPage() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }} style={{ backgroundColor: group.color || undefined }}
/> />
<Label htmlFor={`group-${group.id}`} className="text-sm"> <Label htmlFor={`group-${group.id}`} className="text-sm">
{group.name} {group.name}
+19 -1
View File
@@ -9,6 +9,7 @@ import {
Cloud, Cloud,
Send, Send,
Code, Code,
Plug,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
import WeatherSettings from "./WeatherSettings"; import WeatherSettings from "./WeatherSettings";
import WebhookBAPConfig from "./WebhookBAPConfig"; import WebhookBAPConfig from "./WebhookBAPConfig";
import SQLExecutor from "./SQLExecutor"; import SQLExecutor from "./SQLExecutor";
import ExternalApiSettings from "./ExternalApiSettings";
export default function Utilities() { export default function Utilities() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
@@ -31,6 +33,7 @@ export default function Utilities() {
if (path === '/nocodb-config') return 'nocodb'; if (path === '/nocodb-config') return 'nocodb';
if (path === '/database-debug') return 'debug'; if (path === '/database-debug') return 'debug';
if (path === '/weather-settings') return 'weather'; if (path === '/weather-settings') return 'weather';
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
return 'backups'; return 'backups';
}); });
@@ -41,6 +44,7 @@ export default function Utilities() {
const canManageWeather = user?.role === 'admin'; const canManageWeather = user?.role === 'admin';
const canManageWebhookBAP = user?.role === 'admin'; const canManageWebhookBAP = user?.role === 'admin';
const canExecuteSQL = user?.role === 'admin'; const canExecuteSQL = user?.role === 'admin';
const canManageExternalApi = user?.role === 'admin';
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé // Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) { if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
return ( return (
@@ -78,7 +82,7 @@ export default function Utilities() {
{/* Content avec onglets */} {/* Content avec onglets */}
<div className="flex-1 p-6 overflow-hidden"> <div className="flex-1 p-6 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-6 mb-6"> <TabsList className="flex flex-wrap h-auto w-full justify-start gap-1 mb-6">
{canManageBackups && ( {canManageBackups && (
<TabsTrigger value="backups" className="flex items-center gap-2"> <TabsTrigger value="backups" className="flex items-center gap-2">
<Database className="w-4 h-4" /> <Database className="w-4 h-4" />
@@ -109,6 +113,12 @@ export default function Utilities() {
Configuration BAP Configuration BAP
</TabsTrigger> </TabsTrigger>
)} )}
{canManageExternalApi && (
<TabsTrigger value="api" className="flex items-center gap-2">
<Plug className="w-4 h-4" />
API externe
</TabsTrigger>
)}
{canExecuteSQL && ( {canExecuteSQL && (
<TabsTrigger value="sql" className="flex items-center gap-2"> <TabsTrigger value="sql" className="flex items-center gap-2">
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
@@ -158,6 +168,14 @@ export default function Utilities() {
</TabsContent> </TabsContent>
)} )}
{canManageExternalApi && (
<TabsContent value="api" className="flex-1 overflow-hidden">
<div className="h-full bg-gray-50 -m-6 p-6">
<ExternalApiSettings />
</div>
</TabsContent>
)}
{canExecuteSQL && ( {canExecuteSQL && (
<TabsContent value="sql" className="flex-1 overflow-hidden"> <TabsContent value="sql" className="flex-1 overflow-hidden">
<div className="h-full bg-gray-50 -m-6 p-6"> <div className="h-full bg-gray-50 -m-6 p-6">
+37 -18
View File
@@ -2,7 +2,7 @@
* MobileAvoirsPage.tsx * MobileAvoirsPage.tsx
* Version mobile de la page Gestion des Avoirs * Version mobile de la page Gestion des Avoirs
*/ */
import { useState } from "react"; import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -68,16 +68,20 @@ const avoirSchema = z.object({
status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"), status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"),
}); });
// Tableau vide stable tant que la liste n'est pas chargée (calculs mémoïsés)
const NO_AVOIRS: any[] = [];
export default function MobileAvoirsPage() { export default function MobileAvoirsPage() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); // Magasins déjà chargés par l'application (même liste que /api/groups)
const { selectedStoreId, stores } = useStore();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Queries // Queries
const { data: avoirs = [], isLoading } = useQuery({ const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<any[]>({
queryKey: ["/api/avoirs", selectedStoreId], queryKey: ["/api/avoirs", selectedStoreId],
queryFn: async () => { queryFn: async () => {
if (!selectedStoreId) return []; if (!selectedStoreId) return [];
@@ -88,14 +92,11 @@ export default function MobileAvoirsPage() {
enabled: !!selectedStoreId && !!user, enabled: !!selectedStoreId && !!user,
}); });
// Fournisseurs : utiles seulement dans la feuille de création, chargés à son ouverture
const { data: suppliers = [] } = useQuery({ const { data: suppliers = [] } = useQuery({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
queryFn: () => apiRequest('/api/suppliers') queryFn: () => apiRequest('/api/suppliers'),
}); enabled: isCreateOpen,
const { data: groups = [] } = useQuery({
queryKey: ['/api/groups'],
queryFn: () => apiRequest('/api/groups')
}); });
// Mutations // Mutations
@@ -142,7 +143,7 @@ export default function MobileAvoirsPage() {
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
let groupId = selectedStoreId; let groupId = selectedStoreId;
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId; if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id; if (!groupId && stores.length > 0 && user?.role === 'admin') groupId = stores[0].id;
if (!groupId) { if (!groupId) {
toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" }); toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" });
@@ -155,13 +156,31 @@ export default function MobileAvoirsPage() {
}); });
}; };
const sortedAvoirs = [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); // Tri et filtrage recalculés seulement quand la liste ou la recherche change
const sortedAvoirs = useMemo(
const filteredAvoirs = sortedAvoirs.filter((a: any) => () => [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
(a.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || "") || [avoirs]
(a.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || "")
); );
const filteredAvoirs = useMemo(() => {
const searchLower = searchTerm.toLowerCase();
return sortedAvoirs.filter((a: any) =>
(a.supplier?.name?.toLowerCase().includes(searchLower) || "") ||
(a.invoiceReference?.toLowerCase().includes(searchLower) || "")
);
}, [sortedAvoirs, searchTerm]);
// Changement de statut : le PUT remet à null montant, référence et
// commentaire absents du corps. On envoie donc le statut et les valeurs
// actuelles de ces trois champs (une valeur nulle est omise et reste nulle),
// sans l'avoir complet et ses relations (fournisseur, magasin, créateur).
const buildStatusUpdate = (avoir: any, status: string) => ({
status,
amount: avoir.amount ?? null,
...(avoir.invoiceReference != null ? { invoiceReference: avoir.invoiceReference } : {}),
...(avoir.comment != null ? { comment: avoir.comment } : {}),
});
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case "En attente de demande": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>; case "En attente de demande": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>;
@@ -238,19 +257,19 @@ export default function MobileAvoirsPage() {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({ <DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id, id: avoir.id,
data: { ...avoir, status: 'En attente de demande' } data: buildStatusUpdate(avoir, 'En attente de demande')
})}> })}>
Marquer En attente Marquer En attente
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({ <DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id, id: avoir.id,
data: { ...avoir, status: 'Demandé' } data: buildStatusUpdate(avoir, 'Demandé')
})}> })}>
Marquer Demandé Marquer Demandé
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({ <DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id, id: avoir.id,
data: { ...avoir, status: 'Reçu' } data: buildStatusUpdate(avoir, 'Reçu')
})}> })}>
Marquer Reçu Marquer Reçu
</DropdownMenuItem> </DropdownMenuItem>
+32 -18
View File
@@ -2,7 +2,7 @@
* CalendarPage Mobile - Calendrier simplifié pour mobile * CalendarPage Mobile - Calendrier simplifié pour mobile
* Affiche les commandes et livraisons comme événements * Affiche les commandes et livraisons comme événements
*/ */
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -42,6 +42,10 @@ type CalendarEvent = {
supplier?: { name: string }; supplier?: { name: string };
}; };
// Références stables (évite de recalculer l'index des événements à chaque rendu)
const NO_DATA: any[] = [];
const NO_EVENTS: CalendarEvent[] = [];
export default function MobileCalendarPage() { export default function MobileCalendarPage() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -49,7 +53,7 @@ export default function MobileCalendarPage() {
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date()); const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
// Fetch orders for current month // Fetch orders for current month
const { data: orders = [] } = useQuery({ const { data: orders = NO_DATA } = useQuery({
queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')], queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')],
queryFn: async () => { queryFn: async () => {
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd'); const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
@@ -74,7 +78,7 @@ export default function MobileCalendarPage() {
}); });
// Fetch deliveries for current month // Fetch deliveries for current month
const { data: deliveries = [] } = useQuery({ const { data: deliveries = NO_DATA } = useQuery({
queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')], queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')],
queryFn: async () => { queryFn: async () => {
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd'); const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
@@ -98,7 +102,10 @@ export default function MobileCalendarPage() {
enabled: !!user, enabled: !!user,
}); });
// Combine orders and deliveries into events // Combine orders and deliveries into events, indexés par jour ('yyyy-MM-dd',
// heure locale comme isSameDay) une seule fois par chargement au lieu d'un
// filtrage complet pour chaque case du calendrier
const { events, eventsByDay } = useMemo(() => {
const events: CalendarEvent[] = [ const events: CalendarEvent[] = [
...orders.map((order: any) => ({ ...orders.map((order: any) => ({
id: order.id, id: order.id,
@@ -118,6 +125,23 @@ export default function MobileCalendarPage() {
})) }))
]; ];
const eventsByDay = new Map<string, CalendarEvent[]>();
for (const event of events) {
if (!event.date) continue;
const eventDate = new Date(event.date);
if (isNaN(eventDate.getTime())) continue;
const key = format(eventDate, 'yyyy-MM-dd');
const dayEvents = eventsByDay.get(key);
if (dayEvents) {
dayEvents.push(event);
} else {
eventsByDay.set(key, [event]);
}
}
return { events, eventsByDay };
}, [orders, deliveries]);
const monthStart = startOfMonth(currentMonth); const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth); const monthEnd = endOfMonth(currentMonth);
@@ -126,24 +150,14 @@ export default function MobileCalendarPage() {
const calendarEnd = endOfWeek(monthEnd, { locale: fr }); const calendarEnd = endOfWeek(monthEnd, { locale: fr });
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
// Get events for selected date
const selectedDateEvents = selectedDate
? events.filter((event) => {
if (!event.date) return false;
const eventDate = new Date(event.date);
return !isNaN(eventDate.getTime()) && isSameDay(eventDate, selectedDate);
})
: [];
// Check if a day has events // Check if a day has events
const getEventsForDay = (day: Date) => { const getEventsForDay = (day: Date) => {
return events.filter((event) => { return eventsByDay.get(format(day, 'yyyy-MM-dd')) || NO_EVENTS;
if (!event.date) return false;
const eventDate = new Date(event.date);
return !isNaN(eventDate.getTime()) && isSameDay(eventDate, day);
});
}; };
// Get events for selected date
const selectedDateEvents = selectedDate ? getEventsForDay(selectedDate) : NO_EVENTS;
const navigateMonth = (direction: 'prev' | 'next') => { const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(direction === 'prev' setCurrentMonth(direction === 'prev'
? subMonths(currentMonth, 1) ? subMonths(currentMonth, 1)
+17 -10
View File
@@ -2,7 +2,7 @@
* MobileCustomerOrdersPage.tsx * MobileCustomerOrdersPage.tsx
* Version mobile de la page Commandes Clients avec création * Version mobile de la page Commandes Clients avec création
*/ */
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -23,8 +23,7 @@ import {
X, X,
Filter, Filter,
Loader2, Loader2,
MessageSquare, MessageSquare
Search
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
@@ -78,6 +77,9 @@ const customerOrderFormSchema = z.object({
deposit: z.coerce.number().optional().default(0), deposit: z.coerce.number().optional().default(0),
}); });
// Référence stable tant que la liste n'est pas chargée
const NO_ORDERS: any[] = [];
export default function MobileCustomerOrdersPage() { export default function MobileCustomerOrdersPage() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
@@ -89,7 +91,7 @@ export default function MobileCustomerOrdersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Queries // Queries
const { data: orders = [], isLoading } = useQuery({ const { data: orders = NO_ORDERS, isLoading } = useQuery({
queryKey: ["/api/customer-orders", selectedStoreId], queryKey: ["/api/customer-orders", selectedStoreId],
queryFn: async () => { queryFn: async () => {
if (!selectedStoreId) return []; if (!selectedStoreId) return [];
@@ -268,11 +270,16 @@ export default function MobileCustomerOrdersPage() {
createMutation.mutate(submitData); createMutation.mutate(submitData);
}; };
const filteredOrders = orders.filter((order: any) => // Recalculé seulement quand la liste ou la recherche change (et non à
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) || // chaque frappe dans le commentaire de contact)
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) || const filteredOrders = useMemo(() => {
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) const search = searchTerm.toLowerCase();
return orders.filter((order: any) =>
order.customerName.toLowerCase().includes(search) ||
order.productDesignation.toLowerCase().includes(search) ||
(order.productReference && order.productReference.toLowerCase().includes(search))
); );
}, [orders, searchTerm]);
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
@@ -553,7 +560,7 @@ export default function MobileCustomerOrdersPage() {
<FormField <FormField
control={form.control} control={form.control}
name="isPromotionalPrice" name={"isPromotionalPrice" as any}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl> <FormControl>
@@ -573,7 +580,7 @@ export default function MobileCustomerOrdersPage() {
<FormField <FormField
control={form.control} control={form.control}
name="customerNotified" name={"customerNotified" as any}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl> <FormControl>
+14 -22
View File
@@ -2,6 +2,7 @@
* DashboardPage Mobile - Tableau de bord simplifié pour mobile * DashboardPage Mobile - Tableau de bord simplifié pour mobile
*/ */
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import MobileLayout from "./MobileLayout"; import MobileLayout from "./MobileLayout";
@@ -59,22 +60,6 @@ export default function MobileDashboardPage() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
// Fetch stats
const { data: stats, isLoading } = useQuery({
queryKey: ["/api/stats/dashboard", selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
const response = await fetch(`/api/stats/dashboard?${params}`, {
credentials: 'include'
});
if (!response.ok) return null;
return response.json();
},
enabled: !!user,
});
// Fetch pending tasks count // Fetch pending tasks count
const { data: tasks = [] } = useQuery({ const { data: tasks = [] } = useQuery({
queryKey: ["/api/tasks", selectedStoreId], queryKey: ["/api/tasks", selectedStoreId],
@@ -92,13 +77,18 @@ export default function MobileDashboardPage() {
enabled: !!user, enabled: !!user,
}); });
// Date du jour : le serveur filtre sur startDate/endDate (le paramètre 'date'
// était ignoré et renvoyait tout l'historique)
const today = format(new Date(), 'yyyy-MM-dd');
// Fetch today's orders // Fetch today's orders
const { data: orders = [] } = useQuery({ const { data: orders = [] } = useQuery({
queryKey: ["/api/orders/today", selectedStoreId], queryKey: ["/api/orders/today", selectedStoreId, today],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
params.append('date', format(new Date(), 'yyyy-MM-dd')); params.append('startDate', today);
params.append('endDate', today);
const response = await fetch(`/api/orders?${params}`, { const response = await fetch(`/api/orders?${params}`, {
credentials: 'include' credentials: 'include'
@@ -112,11 +102,12 @@ export default function MobileDashboardPage() {
// Fetch today's deliveries // Fetch today's deliveries
const { data: deliveries = [] } = useQuery({ const { data: deliveries = [] } = useQuery({
queryKey: ["/api/deliveries/today", selectedStoreId], queryKey: ["/api/deliveries/today", selectedStoreId, today],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
params.append('date', format(new Date(), 'yyyy-MM-dd')); params.append('startDate', today);
params.append('endDate', today);
const response = await fetch(`/api/deliveries?${params}`, { const response = await fetch(`/api/deliveries?${params}`, {
credentials: 'include' credentials: 'include'
@@ -183,15 +174,16 @@ export default function MobileDashboardPage() {
{ icon: Calendar, label: "Agenda", path: "/calendar" }, { icon: Calendar, label: "Agenda", path: "/calendar" },
].map((item) => { ].map((item) => {
const Icon = item.icon; const Icon = item.icon;
// Lien wouter : navigation interne sans recharger l'application
return ( return (
<a <Link
key={item.path} key={item.path}
href={item.path} href={item.path}
className="flex flex-col items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100" className="flex flex-col items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100"
> >
<Icon className="h-6 w-6 text-gray-600 mb-1" /> <Icon className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs text-gray-600">{item.label}</span> <span className="text-xs text-gray-600">{item.label}</span>
</a> </Link>
); );
})} })}
</div> </div>
+24 -14
View File
@@ -70,30 +70,40 @@ const dlcFormSchema = z.object({
notes: z.string().optional(), notes: z.string().optional(),
}); });
// Référence stable tant que la liste n'est pas chargée
const NO_PRODUCTS: any[] = [];
export default function MobileDlcPage() { export default function MobileDlcPage() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Recherche appliquée 300 ms après la dernière frappe (filtrage local)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Data fetching // Data fetching
const { data: dlcProducts = [], isLoading } = useQuery({ // Toute la liste du magasin est chargée une seule fois : l'onglet et la
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm], // recherche filtrent localement, sans nouvelle requête (clé propre au mobile,
// toujours invalidée par le préfixe "/api/dlc-products" des mutations)
const { data: dlcProducts = NO_PRODUCTS, isLoading } = useQuery({
queryKey: ["/api/dlc-products", "mobile", selectedStoreId],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); if (!selectedStoreId) return [];
if (selectedStoreId) params.append("storeId", selectedStoreId.toString()); return apiRequest(`/api/dlc-products?storeId=${selectedStoreId}`);
// Simplify fetching everything client side for mobile smoothness or filter server side?
// Let's filter client side for better UX given the likely smaller dataset per store
if (selectedStoreId) {
const res = await fetch(`/api/dlc-products?storeId=${selectedStoreId}`, { credentials: 'include' });
return res.json();
}
return [];
}, },
enabled: !!selectedStoreId && !!user, enabled: !!selectedStoreId && !!user,
staleTime: 2 * 60 * 1000, // 2 minutes, comme sur ordinateur
}); });
const { data: suppliers = [] } = useQuery({ const { data: suppliers = [] } = useQuery({
@@ -230,8 +240,8 @@ export default function MobileDlcPage() {
let filtered = dlcProducts; let filtered = dlcProducts;
// Search // Search
if (searchTerm) { if (debouncedSearchTerm) {
const lower = searchTerm.toLowerCase(); const lower = debouncedSearchTerm.toLowerCase();
filtered = filtered.filter((p: any) => filtered = filtered.filter((p: any) =>
p.productName.toLowerCase().includes(lower) || p.productName.toLowerCase().includes(lower) ||
(p.gencode && p.gencode.includes(lower)) (p.gencode && p.gencode.includes(lower))
@@ -254,7 +264,7 @@ export default function MobileDlcPage() {
}); });
return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime()); return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime());
}, [dlcProducts, searchTerm, statusFilter]); }, [dlcProducts, debouncedSearchTerm, statusFilter]);
const getDaysBadge = (dateStr: string) => { const getDaysBadge = (dateStr: string) => {
const today = new Date(); const today = new Date();
+25 -4
View File
@@ -2,16 +2,33 @@
* MobileApp - Wrapper pour l'application mobile avec StoreProvider * MobileApp - Wrapper pour l'application mobile avec StoreProvider
* Fournit le contexte Store à toutes les pages mobiles * Fournit le contexte Store à toutes les pages mobiles
*/ */
import { ReactNode, useState, useEffect } from "react"; import { ReactNode, Suspense, useState, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { StoreProvider } from "@/contexts/StoreContext"; import { StoreProvider } from "@/contexts/StoreContext";
import MobileLayout from "./MobileLayout";
import type { Group } from "@shared/schema"; import type { Group } from "@shared/schema";
interface MobileAppProps { interface MobileAppProps {
children: ReactNode; children: ReactNode;
} }
// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
// rendu tant que /api/groups n'a pas répondu
const EMPTY_STORES: Group[] = [];
// Affiché pendant le chargement du code d'une page : en-tête et barre de
// navigation restent visibles
function MobilePageFallback() {
return (
<MobileLayout>
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</MobileLayout>
);
}
export default function MobileApp({ children }: MobileAppProps) { export default function MobileApp({ children }: MobileAppProps) {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
@@ -26,7 +43,7 @@ export default function MobileApp({ children }: MobileAppProps) {
const [storeInitialized, setStoreInitialized] = useState(false); const [storeInitialized, setStoreInitialized] = useState(false);
// Fetch stores // Fetch stores
const { data: stores = [] } = useQuery<Group[]>({ const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
queryKey: ['/api/groups'], queryKey: ['/api/groups'],
enabled: !!user, enabled: !!user,
}); });
@@ -52,7 +69,9 @@ export default function MobileApp({ children }: MobileAppProps) {
} }
}, [selectedStoreId]); }, [selectedStoreId]);
const storeContextValue = { // Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
// re-rendus que lorsqu'une de ces valeurs change réellement
const storeContextValue = useMemo(() => ({
selectedStoreId, selectedStoreId,
setSelectedStoreId, setSelectedStoreId,
stores, stores,
@@ -61,11 +80,13 @@ export default function MobileApp({ children }: MobileAppProps) {
mobileMenuOpen, mobileMenuOpen,
setMobileMenuOpen, setMobileMenuOpen,
storeInitialized storeInitialized
}; }), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
return ( return (
<StoreProvider value={storeContextValue}> <StoreProvider value={storeContextValue}>
<Suspense fallback={<MobilePageFallback />}>
{children} {children}
</Suspense>
</StoreProvider> </StoreProvider>
); );
} }
+34 -108
View File
@@ -1,137 +1,63 @@
/** /**
* MobileBottomNav - Navigation bottom fixe pour l'application mobile * MobileBottomNav - Barre de navigation fixe en bas de l'écran (téléphone)
* 5 boutons: Accueil, Commandes, Livraisons, Tâches, Plus * 4 raccourcis tirés de la configuration du menu (selon le rôle) + « Menu »
*/ */
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useState } from "react"; import { Menu } from "lucide-react";
import {
Home,
Package,
Truck,
CheckSquare,
MoreHorizontal,
Calendar,
Megaphone,
ShoppingCart,
Clock,
Wrench,
Receipt
} from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { getMobileShortcuts, isNavItemActive } from "@/lib/navigation";
interface NavItem { interface MobileBottomNavProps {
path: string; menuOpen: boolean;
label: string; onOpenMenu: () => void;
icon: React.ComponentType<{ className?: string }>;
} }
const primaryItems: NavItem[] = [ export default function MobileBottomNav({ menuOpen, onOpenMenu }: MobileBottomNavProps) {
{ path: "/dashboard", label: "Accueil", icon: Home },
{ path: "/orders", label: "Commandes", icon: Package },
{ path: "/deliveries", label: "Livraisons", icon: Truck },
{ path: "/tasks", label: "Tâches", icon: CheckSquare },
];
const moreItems: NavItem[] = [
{ path: "/calendar", label: "Calendrier", icon: Calendar },
{ path: "/publicities", label: "Publicités", icon: Megaphone },
{ path: "/customer-orders", label: "Cmd Client", icon: ShoppingCart },
{ path: "/dlc", label: "DLC", icon: Clock },
{ path: "/sav", label: "SAV", icon: Wrench },
{ path: "/avoirs", label: "Avoirs", icon: Receipt },
];
export default function MobileBottomNav() {
const [location] = useLocation(); const [location] = useLocation();
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const [moreOpen, setMoreOpen] = useState(false); const shortcuts = getMobileShortcuts(user?.role);
const isActive = (path: string) => { // « Menu » est mis en avant quand la page affichée n'est pas un raccourci
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true; const menuActive = menuOpen || !shortcuts.some((item) => isNavItemActive(item.path, location));
return location.startsWith(path);
};
// Check if any "more" item is active const itemClasses = (active: boolean) =>
const isMoreActive = moreItems.some(item => isActive(item.path)); `flex flex-1 flex-col items-center justify-center gap-0.5 min-w-0 h-full px-1 text-xs font-medium leading-tight text-center ${active ? 'text-blue-700' : 'text-gray-600'}`;
return ( return (
<>
{/* Navigation bottom fixe */}
<nav <nav
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50" className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Navigation principale"
> >
<div className="flex items-center justify-around h-16"> <div className="flex items-stretch h-16">
{/* Items principaux */} {shortcuts.map((item) => {
{primaryItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = isActive(item.path); const active = isNavItemActive(item.path, location);
return (
<Link key={item.path} href={item.path}>
<div className={`flex flex-col items-center justify-center min-w-[60px] py-1 ${active ? 'text-blue-600' : 'text-gray-500'
}`}>
<Icon className={`h-5 w-5 ${active ? 'text-blue-600' : 'text-gray-500'}`} />
<span className={`text-[10px] mt-0.5 font-medium ${active ? 'text-blue-600' : 'text-gray-500'
}`}>
{item.label}
</span>
</div>
</Link>
);
})}
{/* Bouton "Plus" */}
<div
className={`flex flex-col items-center justify-center min-w-[60px] py-1 cursor-pointer ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`}
onClick={() => setMoreOpen(true)}
>
<MoreHorizontal className={`h-5 w-5 ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`} />
<span className={`text-[10px] mt-0.5 font-medium ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`}>
Plus
</span>
</div>
</div>
</nav>
{/* Sheet "Plus" */}
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent side="bottom" className="h-auto max-h-[50vh] rounded-t-xl">
<SheetHeader className="pb-4">
<SheetTitle>Autres pages</SheetTitle>
</SheetHeader>
<div className="grid grid-cols-3 gap-3 pb-6">
{moreItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return ( return (
<Link <Link
key={item.path} key={item.path}
href={item.path} href={item.path}
onClick={() => setMoreOpen(false)} aria-current={active ? "page" : undefined}
className={itemClasses(active)}
> >
<div className={`flex flex-col items-center justify-center p-4 rounded-xl min-h-[80px] ${active <Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
? 'bg-blue-100 text-blue-700' <span className="line-clamp-2">{item.label}</span>
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}>
<Icon className={`h-6 w-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-xs mt-2 font-medium text-center ${active ? 'text-blue-700' : 'text-gray-700'
}`}>
{item.label}
</span>
</div>
</Link> </Link>
); );
})} })}
<button
type="button"
onClick={onOpenMenu}
aria-haspopup="dialog"
aria-expanded={menuOpen}
className={itemClasses(menuActive)}
>
<Menu className="h-5 w-5 shrink-0" aria-hidden="true" />
<span>Menu</span>
</button>
</div> </div>
</SheetContent> </nav>
</Sheet>
</>
); );
} }
+83 -55
View File
@@ -1,15 +1,18 @@
/** /**
* MobileLayout - Layout dédié pour l'application mobile * MobileLayout - Cadre de l'application sur téléphone
* Affiche le magasin sélectionné de manière visible et une navigation bottom * En-tête (titre de la page + magasin), contenu défilant, barre du bas et
* menu complet, tous tirés de la même configuration que le menu PC
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur * Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
*/ */
import { ReactNode, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { Menu, Store, ChevronDown, LogOut } from "lucide-react"; import { Link, useLocation } from "wouter";
import { Boxes, ChevronDown, LogOut } from "lucide-react";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getNavItemForPath, getNavSections, getRoleLabel, isNavItemActive } from "@/lib/navigation";
import MobileBottomNav from "./MobileBottomNav"; import MobileBottomNav from "./MobileBottomNav";
import type { Group } from "@shared/schema"; import type { Group } from "@shared/schema";
@@ -20,10 +23,16 @@ interface MobileLayoutProps {
export default function MobileLayout({ children, title }: MobileLayoutProps) { export default function MobileLayout({ children, title }: MobileLayoutProps) {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const [location] = useLocation();
const { selectedStoreId, setSelectedStoreId, stores } = useStore(); const { selectedStoreId, setSelectedStoreId, stores } = useStore();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
// Get selected store name // Titre de l'onglet du navigateur tiré du menu, comme sur PC
useEffect(() => {
const navItem = getNavItemForPath(location);
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
}, [location]);
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId); const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
const storeName = selectedStore?.name || "Tous les magasins"; const storeName = selectedStore?.name || "Tous les magasins";
@@ -47,6 +56,11 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
setSelectedStoreId(newStoreId); setSelectedStoreId(newStoreId);
}; };
const sections = getNavSections(user?.role);
const displayName = user && (user.firstName || user.lastName
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
: user.username);
return ( return (
<div <div
className="bg-gray-50 flex flex-col" className="bg-gray-50 flex flex-col"
@@ -62,46 +76,38 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
bottom: 0 bottom: 0
}} }}
> >
{/* Header fixe */} {/* En-tête : logo + magasin actif (le titre est porté par chaque page) */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50"> <header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="flex items-center justify-between h-14 px-3"> <div className="flex items-center justify-between gap-3 h-14 px-4">
{/* Menu hamburger */} <Link href="/" className="flex items-center gap-2 shrink-0">
<Button <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-600">
variant="ghost" <Boxes className="h-4 w-4 text-white" aria-hidden="true" />
size="sm" </span>
onClick={() => setMenuOpen(true)}
className="h-10 w-10 p-0"
>
<Menu className="h-5 w-5" />
</Button>
{/* Logo */}
<div className="flex items-center gap-1">
<Store className="h-5 w-5 text-blue-600" />
<span className="font-semibold text-gray-900">LogiFlow</span> <span className="font-semibold text-gray-900">LogiFlow</span>
</div> </Link>
{/* Magasin sélectionné - bien visible */} <button
<div type="button"
className="flex items-center gap-1 bg-blue-50 px-2 py-1 rounded-lg cursor-pointer" className="flex items-center gap-1 bg-blue-50 px-2.5 py-1.5 rounded-lg min-w-0 shrink"
onClick={() => setMenuOpen(true)} onClick={() => setMenuOpen(true)}
aria-label={`Magasin : ${storeName}. Ouvrir le menu`}
> >
<span className="text-xs font-medium text-blue-700 truncate max-w-[80px]"> <span className="text-sm font-medium text-blue-700 truncate max-w-[140px]">
{storeName} {storeName}
</span> </span>
<ChevronDown className="h-3 w-3 text-blue-600" /> <ChevronDown className="h-4 w-4 text-blue-600 shrink-0" aria-hidden="true" />
</div> </button>
</div> </div>
{/* Titre de page optionnel */} {/* Titre de page optionnel */}
{title && ( {title && (
<div className="px-3 pb-2"> <div className="px-4 pb-2">
<h1 className="text-lg font-bold text-gray-900">{title}</h1> <h1 className="text-lg font-bold text-gray-900">{title}</h1>
</div> </div>
)} )}
</header> </header>
{/* Contenu principal - scrollable avec espace pour navbar */} {/* Contenu principal - défilant, avec la place de la barre du bas */}
<main <main
className="flex-1 overflow-x-hidden" className="flex-1 overflow-x-hidden"
style={{ style={{
@@ -113,35 +119,50 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
{children} {children}
</main> </main>
{/* Navigation bottom */} <MobileBottomNav menuOpen={menuOpen} onOpenMenu={() => setMenuOpen(true)} />
<MobileBottomNav />
{/* Menu latéral (Sheet) */} {/* Menu complet : tous les modules autorisés, magasin, profil */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="left" className="w-[280px] p-0"> <SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-xl p-0">
<SheetHeader className="p-4 border-b"> <SheetHeader className="px-4 pt-4 pb-2 text-left">
<SheetTitle className="flex items-center gap-2"> <SheetTitle>Menu</SheetTitle>
<Store className="h-5 w-5 text-blue-600" />
LogiFlow
</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="p-4 space-y-6"> <nav className="px-4 space-y-4" aria-label="Menu principal">
{/* Utilisateur */} {sections.map((section) => (
{user && ( <div key={section.title ?? 'accueil'}>
<div className="bg-gray-50 rounded-lg p-3"> {section.title && (
<p className="font-medium text-gray-900"> <h2 className="mb-2 text-xs font-semibold text-gray-500">{section.title}</h2>
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500 capitalize">{user.role}</p>
</div>
)} )}
<div className="grid grid-cols-3 gap-2">
{section.items.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(item.path, location);
{/* Sélecteur de magasin */} return (
<Link
key={item.path}
href={item.path}
onClick={() => setMenuOpen(false)}
aria-current={active ? "page" : undefined}
className={`flex flex-col items-center justify-center gap-1.5 rounded-xl p-2 min-h-[76px] text-center text-xs font-medium leading-tight ${active
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
: 'bg-gray-100 text-gray-800 active:bg-gray-200'
}`}
>
<Icon className={`h-6 w-6 ${active ? 'text-blue-700' : 'text-gray-600'}`} aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</div>
))}
</nav>
<div className="mt-4 border-t border-gray-200 p-4 space-y-4" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">Magasin</label>
Magasin
</label>
<Select <Select
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")} value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
onValueChange={handleStoreChange} onValueChange={handleStoreChange}
@@ -163,7 +184,7 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: store.color || '#gray' }} style={{ backgroundColor: store.color || '#9ca3af' }}
/> />
{store.name} {store.name}
</div> </div>
@@ -173,16 +194,23 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
</Select> </Select>
</div> </div>
{/* Déconnexion */} {user && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">{displayName}</p>
<p className="text-sm text-gray-600">{getRoleLabel(user.role)}</p>
</div>
<Button <Button
variant="outline" variant="outline"
className="w-full h-12 text-red-600 border-red-200 hover:bg-red-50" className="h-11 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="h-4 w-4 mr-2" />
Déconnexion Déconnexion
</Button> </Button>
</div> </div>
)}
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>
+101 -25
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -32,9 +32,13 @@ type TaskWithRelations = Task & {
isFutureTask?: boolean; isFutureTask?: boolean;
}; };
// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres)
const NO_TASKS: TaskWithRelations[] = [];
// Mobile Task Form Component // Mobile Task Form Component
function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) { function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -84,7 +88,9 @@ function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
title: "Succès", title: "Succès",
description: task ? "Tâche modifiée" : "Tâche créée", description: task ? "Tâche modifiée" : "Tâche créée",
}); });
window.location.reload(); // Rechargement ciblé de la liste (plus de rechargement complet de la page)
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
onClose();
} catch (error) { } catch (error) {
toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" }); toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" });
@@ -291,9 +297,11 @@ export default function MobileTasksPage() {
const [editingTask, setEditingTask] = useState<TaskWithRelations | null>(null); const [editingTask, setEditingTask] = useState<TaskWithRelations | null>(null);
const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null); const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null);
const tasksQueryKey = ["/api/tasks", selectedStoreId];
// Fetch tasks // Fetch tasks
const { data: tasks = [], isLoading } = useQuery({ const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({
queryKey: ["/api/tasks", selectedStoreId], queryKey: tasksQueryKey,
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
@@ -305,17 +313,22 @@ export default function MobileTasksPage() {
return Array.isArray(data) ? data.filter((t: any) => t && t.id) : []; return Array.isArray(data) ? data.filter((t: any) => t && t.id) : [];
}, },
enabled: !!user, enabled: !!user,
// Au changement de magasin, la liste précédente reste affichée (estompée et
// non cliquable, avec un indicateur) au lieu d'un spinner plein écran
placeholderData: keepPreviousData,
}); });
// Filter tasks // Filter tasks (recalculé seulement quand la liste, l'onglet ou la recherche change)
const filteredTasks = tasks const filteredTasks = useMemo(() => {
const search = searchTerm.toLowerCase();
return tasks
.filter((task: TaskWithRelations) => { .filter((task: TaskWithRelations) => {
// Tab filter // Tab filter
if (activeTab === 'pending' && task.status === 'completed') return false; if (activeTab === 'pending' && task.status === 'completed') return false;
if (activeTab === 'completed' && task.status !== 'completed') return false; if (activeTab === 'completed' && task.status !== 'completed') return false;
// Search filter // Search filter
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase())) { if (search && !task.title.toLowerCase().includes(search)) {
return false; return false;
} }
return true; return true;
@@ -327,33 +340,86 @@ export default function MobileTasksPage() {
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) - return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2); (priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
}); });
}, [tasks, activeTab, searchTerm]);
// Handlers // Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
const handleComplete = async (taskId: number) => { // pouvoir le restaurer si le serveur refuse l'opération
try { const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
await fetch(`/api/tasks/${taskId}/complete`, { const queryKey = tasksQueryKey;
await queryClient.cancelQueries({ queryKey });
const previousTasks = queryClient.getQueryData<TaskWithRelations[]>(queryKey);
if (Array.isArray(previousTasks)) {
queryClient.setQueryData<TaskWithRelations[]>(queryKey, update(previousTasks));
}
return { queryKey, previousTasks };
};
const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => {
if (context?.previousTasks) {
queryClient.setQueryData(context.queryKey, context.previousTasks);
}
};
// Terminer : la carte passe tout de suite en « terminée », puis la liste est
// rechargée depuis le serveur ; le succès n'est annoncé que si l'API l'accepte
const completeMutation = useMutation({
mutationFn: async (taskId: number) => {
const response = await fetch(`/api/tasks/${taskId}/complete`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); if (!response.ok) throw new Error(`Erreur ${response.status}`);
},
onMutate: (taskId: number) => {
const now = new Date();
return updateCachedTasks((list) => list.map((task) =>
task.id === taskId
? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now }
: task
));
},
onSuccess: () => {
toast({ title: "✅ Tâche terminée" }); toast({ title: "✅ Tâche terminée" });
} catch (error) { },
onError: (_error, _taskId, context) => {
restoreCachedTasks(context);
toast({ title: "Erreur", variant: "destructive" }); toast({ title: "Erreur", variant: "destructive" });
} },
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
},
});
// Supprimer : la carte disparaît et la feuille se ferme tout de suite
const deleteMutation = useMutation({
mutationFn: async (taskId: number) => {
const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) throw new Error(`Erreur ${response.status}`);
},
onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)),
onSuccess: () => {
toast({ title: "🗑️ Tâche supprimée" });
},
onError: (_error, _taskId, context) => {
restoreCachedTasks(context);
toast({ title: "Erreur", variant: "destructive" });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
},
});
// Handlers
const handleComplete = (taskId: number) => {
completeMutation.mutate(taskId);
}; };
const handleDelete = async () => { const handleDelete = () => {
if (!deletingTask) return; if (!deletingTask) return;
try { deleteMutation.mutate(deletingTask.id);
await fetch(`/api/tasks/${deletingTask.id}`, { method: 'DELETE', credentials: 'include' });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({ title: "🗑️ Tâche supprimée" });
setDeletingTask(null); setDeletingTask(null);
} catch (error) {
toast({ title: "Erreur", variant: "destructive" });
}
}; };
const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur'; const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur';
@@ -378,6 +444,12 @@ export default function MobileTasksPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListTodo className="w-5 h-5 text-blue-600" /> <ListTodo className="w-5 h-5 text-blue-600" />
<h1 className="text-lg font-bold">Tâches</h1> <h1 className="text-lg font-bold">Tâches</h1>
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 text-xs text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600" />
Mise à jour…
</span>
)}
</div> </div>
<Badge variant="secondary">{filteredTasks.length}</Badge> <Badge variant="secondary">{filteredTasks.length}</Badge>
</div> </div>
@@ -423,8 +495,12 @@ export default function MobileTasksPage() {
</div> </div>
</div> </div>
{/* Task List */} {/* Task List (liste du magasin précédent estompée et non cliquable pendant le chargement) */}
<div className="px-3 py-2 overflow-hidden" style={{ maxWidth: '100%' }}> <div
className={`px-3 py-2 overflow-hidden ${isPlaceholderData ? 'opacity-60 pointer-events-none transition-opacity' : ''}`}
style={{ maxWidth: '100%' }}
aria-busy={isPlaceholderData}
>
{filteredTasks.length === 0 ? ( {filteredTasks.length === 0 ? (
<div className="text-center py-12 text-gray-500"> <div className="text-center py-12 text-gray-500">
<ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" /> <ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" />
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1758639574 connect.sid s%3A8_9iOHdXBiypBHfh9CFiGGT7-mgFDQ-9.MPmF2NrBG7ywTs2h9HX4yW1JVNQ5dbq%2FHoZpz8jRTpo
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1755861696 connect.sid s%3AI3ww2VSYnJCTbvhTLFfsKWucSCgLEXnH.Q%2FZB%2FqjpNIGDgcSjAp4O%2BHP03Qrmj71qIb8njdhnrK4
-43
View File
@@ -1,43 +0,0 @@
-- Script DEFINITIF pour créer la table webhook_bap_config en production
-- À exécuter directement sur votre PostgreSQL de production
-- ATTENTION : Remplacez YOUR_DATABASE par le nom de votre base
\c YOUR_DATABASE;
-- Créer la table webhook_bap_config
DROP TABLE IF EXISTS webhook_bap_config CASCADE;
CREATE TABLE webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insérer la configuration par défaut avec VOTRE URL
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) VALUES (
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook-test/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration pour envoi des fichiers BAP vers n8n',
true
);
-- Créer la table migrations si elle n'existe pas
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Marquer la migration comme appliquée
INSERT INTO migrations (filename) VALUES ('20250903141000_create_webhook_bap_config.sql')
ON CONFLICT (filename) DO NOTHING;
-- Vérifier que tout est créé
SELECT 'Table webhook_bap_config créée avec succès' as message;
SELECT * FROM webhook_bap_config;
SELECT 'Table migrations mise à jour' as message;
SELECT * FROM migrations WHERE filename LIKE '%webhook_bap%';
-26
View File
@@ -1,26 +0,0 @@
-- Script SQL simple pour créer UNIQUEMENT la table announcements en production
-- À exécuter directement dans le conteneur Docker
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT announcements_priority_check CHECK (priority IN ('normal', 'important', 'urgent')),
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Créer les index pour les performances
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Vérification
SELECT 'Table announcements créée avec succès' as resultat;
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'announcements' ORDER BY ordinal_position;
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
// Script de débogage pour tester les endpoints de production
import https from 'https';
const BASE_URL = 'https://logiflow.ffnancy.fr';
async function makeRequest(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const requestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Debug-Script/1.0',
...options.headers
}
};
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
resolve({
status: res.statusCode,
headers: res.headers,
data: data,
length: data.length
});
});
});
req.on('error', (error) => {
reject(error);
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
async function debugProduction() {
console.log('🔍 Testing production endpoints...\n');
try {
// Test 1: Health check
console.log('1. Health check:');
const health = await makeRequest('/api/health');
console.log(` Status: ${health.status}`);
console.log(` Response: ${health.data.substring(0, 100)}...\n`);
// Test 2: Default credentials check
console.log('2. Default credentials check:');
const defaultCreds = await makeRequest('/api/default-credentials-check');
console.log(` Status: ${defaultCreds.status}`);
console.log(` Response: ${defaultCreds.data}\n`);
// Test 3: Login attempts with different credentials
const credentialsList = [
{ username: 'admin', password: 'admin' },
{ username: 'administrator', password: 'administrator' },
{ username: 'lff', password: 'lff2024' },
{ username: 'logiflow', password: 'logiflow' },
{ username: 'root', password: 'root' }
];
let login = null;
let cookies = '';
for (const creds of credentialsList) {
console.log(`3. Login attempt with ${creds.username}:${creds.password}:`);
login = await makeRequest('/api/login', {
method: 'POST',
body: JSON.stringify(creds)
});
console.log(` Status: ${login.status}`);
console.log(` Response: ${login.data.substring(0, 200)}...\n`);
if (login.status === 200 && login.headers['set-cookie']) {
cookies = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
console.log(`✅ Successful login with ${creds.username}\n`);
break;
}
}
// Cookies already extracted above if login was successful
// Test 4: User info (with cookies)
if (cookies) {
console.log('4. User info (authenticated):');
const user = await makeRequest('/api/user', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${user.status}`);
console.log(` Response: ${user.data.substring(0, 200)}...\n`);
// Test 5: Tasks (with cookies)
console.log('5. Tasks list (authenticated):');
const tasks = await makeRequest('/api/tasks', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${tasks.status}`);
console.log(` Response: ${tasks.data.substring(0, 300)}...\n`);
// Test 6: Test task update (with cookies) - testing the problematic endpoint
console.log('6. Task update test (problematic endpoint):');
const taskUpdate = await makeRequest('/api/tasks/99', {
method: 'PUT',
headers: { 'Cookie': cookies },
body: JSON.stringify({
title: 'Test Debug',
description: 'Test de debug',
priority: 'medium',
status: 'pending',
assignedTo: 'admin',
startDate: '2025-08-20',
dueDate: null
})
});
console.log(` Status: ${taskUpdate.status}`);
console.log(` Response: ${taskUpdate.data}\n`);
} else {
console.log('❌ No cookies received from login - cannot test authenticated endpoints\n');
}
} catch (error) {
console.error('❌ Error during debug:', error.message);
}
}
debugProduction();
-77
View File
@@ -1,77 +0,0 @@
-- 🔍 DIAGNOSTIC PRODUCTION COMPLET - Module Avoirs
-- Copiez-collez ce script dans votre utilitaire SQL pour diagnostic précis
BEGIN;
-- SECTION 1: STRUCTURE DES TABLES
SELECT 'VÉRIFICATION STRUCTURE TABLES' AS section_title;
-- Table avoirs - structure exacte
SELECT 'STRUCTURE_AVOIRS' AS check_type, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'avoirs' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table suppliers - structure exacte
SELECT 'STRUCTURE_SUPPLIERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'suppliers' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table groups - structure exacte
SELECT 'STRUCTURE_GROUPS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'groups' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table users - structure exacte
SELECT 'STRUCTURE_USERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users' AND table_schema = 'public'
ORDER BY ordinal_position;
-- SECTION 2: CONTRAINTES
SELECT 'CONTRAINTES_AVOIRS' AS check_type, conname AS constraint_name, contype AS type,
confrelid::regclass AS referenced_table
FROM pg_constraint
WHERE conrelid = 'avoirs'::regclass;
-- SECTION 3: DONNÉES EXISTANTES
SELECT 'COMPTES_TABLES' AS check_type, 'suppliers' AS table_name, COUNT(*) AS count FROM suppliers
UNION ALL
SELECT 'COMPTES_TABLES', 'groups', COUNT(*) FROM groups
UNION ALL
SELECT 'COMPTES_TABLES', 'users', COUNT(*) FROM users
UNION ALL
SELECT 'COMPTES_TABLES', 'avoirs', COUNT(*) FROM avoirs;
-- SECTION 4: EXEMPLES DE DONNÉES (si elles existent)
-- Suppliers
SELECT 'EXEMPLES_SUPPLIERS' AS check_type, id, name FROM suppliers LIMIT 3;
-- Groups
SELECT 'EXEMPLES_GROUPS' AS check_type, id, name FROM groups LIMIT 3;
-- Users
SELECT 'EXEMPLES_USERS' AS check_type, id,
COALESCE(username, 'no_username') AS username,
role
FROM users LIMIT 3;
-- SECTION 5: TEST DE COMPATIBILITÉ
-- Test si on peut insérer avec des champs NULL
SELECT 'TEST_COMPATIBILITÉ' AS check_type,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'invoice_reference') = 'YES'
THEN 'invoice_reference_NULLABLE'
ELSE 'invoice_reference_NOT_NULL'
END AS invoice_ref_status,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'amount') = 'YES'
THEN 'amount_NULLABLE'
ELSE 'amount_NOT_NULL'
END AS amount_status;
ROLLBACK; -- Ne pas sauvegarder, c'est juste du diagnostic
-15
View File
@@ -1,15 +0,0 @@
# Fichier de configuration Docker Compose pour le développement et la production
# Utilise les variables d'environnement pour la configuration
version: '3.8'
services:
app:
volumes:
# Monter les scripts de migration
- ./scripts:/app/scripts:ro
environment:
# Forcer l'exécution des migrations au démarrage
- AUTO_MIGRATE=true
# Variables de debug
- DEBUG_MIGRATIONS=true
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more