Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a64a73d5a | ||
|
|
b964e137bf | ||
|
|
bd3bb5e64a | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a | ||
|
|
944d99733c | ||
|
|
8a27b310d2 | ||
|
|
6bb61cdbcc | ||
|
|
d384271a30 | ||
|
|
f4c582b217 | ||
|
|
5d3e5357a0 | ||
|
|
a5785f0244 | ||
|
|
3f2660c506 | ||
|
|
3eea2eb47b | ||
|
|
c375229c05 | ||
|
|
bc401963c1 | ||
|
|
6cf779092e | ||
|
|
d09821ddc0 | ||
|
|
a7e44f0601 | ||
|
|
1b9e3ac493 | ||
|
|
4220131432 | ||
|
|
0a816fa881 | ||
|
|
37a8d2b40c | ||
|
|
9dc6d9567c | ||
|
|
f5bc114a82 | ||
|
|
af3800aa09 | ||
|
|
9de717387f | ||
|
|
012024a293 | ||
|
|
baaa27af50 | ||
|
|
02645c34a5 | ||
|
|
02b70d6b59 | ||
|
|
ee4fca64d0 | ||
|
|
753b301066 | ||
|
|
693d9460cb | ||
|
|
a430aa1910 |
No files matched your search
@@ -21,4 +21,18 @@ 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=
|
||||||
@@ -3,4 +3,13 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
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/
|
||||||
@@ -27,7 +27,7 @@ RUN ls -la server/
|
|||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
# Build frontend first
|
# Build frontend first
|
||||||
RUN npx vite build
|
RUN NODE_ENV=production npx vite build
|
||||||
|
|
||||||
# Vérifier que les fichiers sont construits
|
# Vérifier que les fichiers sont construits
|
||||||
RUN echo "=== BUILD VERIFICATION ===" && \
|
RUN echo "=== BUILD VERIFICATION ===" && \
|
||||||
@@ -61,7 +61,9 @@ RUN npx esbuild server/index.production.ts \
|
|||||||
--external:express-rate-limit \
|
--external:express-rate-limit \
|
||||||
--external:memoizee \
|
--external:memoizee \
|
||||||
--external:nanoid \
|
--external:nanoid \
|
||||||
--external:date-fns
|
--external:date-fns \
|
||||||
|
--external:nodemailer \
|
||||||
|
--external:compression
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
@@ -98,6 +100,8 @@ USER nextjs
|
|||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
# Fixé ici car db.ts lit NODE_ENV dès l'import, avant le forçage de index.production.ts
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Install wget for health check and postgresql-client for pg_dump
|
# Install wget for health check and postgresql-client for pg_dump
|
||||||
USER root
|
USER root
|
||||||
|
|||||||
@@ -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');
|
|
||||||
|
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
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 108 KiB |
@@ -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>
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,9 +170,7 @@ export default function AnnouncementCard() {
|
|||||||
: '/api/announcements';
|
: '/api/announcements';
|
||||||
|
|
||||||
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 || '',
|
||||||
|
|||||||
@@ -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,197 +365,158 @@ 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(),
|
const monthStart = startOfMonth(currentDate);
|
||||||
ordersCount: orders?.length || 0,
|
const monthEnd = endOfMonth(currentDate);
|
||||||
deliveriesCount: deliveries?.length || 0,
|
|
||||||
publicitiesCount: publicities?.length || 0,
|
// Get all days in the month
|
||||||
selectedStoreId,
|
const monthDays = eachDayOfInterval({
|
||||||
userGroupsCount: userGroups?.length || 0
|
start: monthStart,
|
||||||
|
end: monthEnd,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
const monthStart = startOfMonth(currentDate);
|
// Pad the calendar to start on Monday
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const firstDayOfWeek = monthStart.getDay();
|
||||||
|
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
const days: Date[] = [];
|
||||||
|
|
||||||
|
// Add padding days from previous month
|
||||||
|
for (let i = startPadding; i > 0; i--) {
|
||||||
|
const paddingDate = new Date(monthStart);
|
||||||
|
paddingDate.setDate(paddingDate.getDate() - i);
|
||||||
|
days.push(paddingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current month days
|
||||||
|
days.push(...monthDays);
|
||||||
|
|
||||||
|
// Add padding days from next month to complete the grid
|
||||||
|
const remainingCells = 42 - days.length; // 6 weeks * 7 days
|
||||||
|
for (let i = 1; i <= remainingCells; i++) {
|
||||||
|
const paddingDate = new Date(monthEnd);
|
||||||
|
paddingDate.setDate(paddingDate.getDate() + i);
|
||||||
|
days.push(paddingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
// Commandes et livraisons indexées par jour ('yyyy-MM-dd', heure locale comme
|
||||||
|
// isSameDay) en une seule passe sur chaque liste, au lieu de filtrer toutes
|
||||||
|
// les listes pour chacune des 42 cases à chaque rendu
|
||||||
|
const itemsByDay = useMemo(() => {
|
||||||
|
const index = new Map<string, { orders: OrderWithRelations[]; deliveries: DeliveryWithRelations[] }>();
|
||||||
|
const getDayEntry = (key: string) => {
|
||||||
|
let entry = index.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { orders: [], deliveries: [] };
|
||||||
|
index.set(key, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
let invalidCount = 0;
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
// Protection contre undefined/null
|
||||||
|
if (!order || !order.supplier) {
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Essayer plusieurs champs de date possibles
|
||||||
|
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
||||||
|
if (orderDate) {
|
||||||
|
getDayEntry(format(orderDate, 'yyyy-MM-dd')).orders.push(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const delivery of deliveries) {
|
||||||
|
// Protection contre undefined/null
|
||||||
|
if (!delivery || !delivery.supplier) {
|
||||||
|
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[]>();
|
||||||
|
|
||||||
// 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
|
// Protection contre les données invalides qui causent des plantages en production
|
||||||
if (!publicities || !Array.isArray(publicities)) {
|
if (!publicities || !Array.isArray(publicities)) {
|
||||||
console.warn('⚠️ Invalid publicities data:', publicities);
|
console.warn('⚠️ Invalid publicities data:', publicities);
|
||||||
return [];
|
return byDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔍 CalendarGrid getPublicitiesForDate:', {
|
|
||||||
date: dateStr,
|
|
||||||
totalPublicities: publicities.length,
|
|
||||||
selectedStoreId,
|
|
||||||
userGroups: userGroups?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return publicities.filter(pub => {
|
let invalidCount = 0;
|
||||||
|
const storePublicities = publicities.filter(pub => {
|
||||||
// Vérifications de sécurité pour éviter les plantages
|
// Vérifications de sécurité pour éviter les plantages
|
||||||
if (!pub || typeof pub !== 'object') {
|
if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) {
|
||||||
console.warn('⚠️ Invalid publicity object:', pub);
|
invalidCount++;
|
||||||
return false;
|
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 no store is selected and user has no assigned groups, show only publicities with participations
|
||||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
return 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 a specific store is selected, check if that store participates
|
||||||
if (selectedStoreId) {
|
if (selectedStoreId) {
|
||||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
return pub.participations && Array.isArray(pub.participations) &&
|
||||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
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,
|
// If no specific store selected but user has assigned stores,
|
||||||
// show publicities where any of user's stores participate
|
// show publicities where any of user's stores participate
|
||||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
return pub.participations && Array.isArray(pub.participations) &&
|
||||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
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
|
// Default case: only show publicities with participations
|
||||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
return 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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ Error filtering publicities:', error);
|
console.error('❌ Error filtering publicities:', error);
|
||||||
return [];
|
byDay.clear();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Get all days in the month
|
return byDay;
|
||||||
const monthDays = eachDayOfInterval({
|
}, [publicities, selectedStoreId, userGroups, paddedDays]);
|
||||||
start: monthStart,
|
|
||||||
end: monthEnd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pad the calendar to start on Monday
|
|
||||||
const firstDayOfWeek = monthStart.getDay();
|
|
||||||
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
|
||||||
const paddedDays = [];
|
|
||||||
|
|
||||||
// Add padding days from previous month
|
|
||||||
for (let i = startPadding; i > 0; i--) {
|
|
||||||
const paddingDate = new Date(monthStart);
|
|
||||||
paddingDate.setDate(paddingDate.getDate() - i);
|
|
||||||
paddedDays.push(paddingDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current month days
|
|
||||||
paddedDays.push(...monthDays);
|
|
||||||
|
|
||||||
// Add padding days from next month to complete the grid
|
|
||||||
const remainingCells = 42 - paddedDays.length; // 6 weeks * 7 days
|
|
||||||
for (let i = 1; i <= remainingCells; i++) {
|
|
||||||
const paddingDate = new Date(monthEnd);
|
|
||||||
paddingDate.setDate(paddingDate.getDate() + i);
|
|
||||||
paddedDays.push(paddingDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
||||||
|
|
||||||
const getItemsForDate = (date: Date) => {
|
|
||||||
// Debug: Log des commandes reçues (seulement une fois)
|
|
||||||
if (orders.length > 0 && date.getDate() === 1) {
|
|
||||||
console.log('📅 CalendarGrid Debug - Orders received:', orders.length);
|
|
||||||
console.log('📅 First order structure:', orders[0]);
|
|
||||||
console.log('📅 All orders dates:', orders.map(o => ({ id: o.id, plannedDate: o.plannedDate, supplier: o.supplier?.name })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log des livraisons reçues (seulement une fois)
|
|
||||||
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
|
|
||||||
if (!order || !order.supplier) {
|
|
||||||
console.warn('📅 Invalid order found:', order);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essayer plusieurs champs de date possibles
|
|
||||||
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
|
||||||
const matches = orderDate && isSameDay(orderDate, date);
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dayDeliveries = deliveries.filter(delivery => {
|
|
||||||
// Protection contre undefined/null
|
|
||||||
if (!delivery || !delivery.supplier) {
|
|
||||||
console.warn('🚛 Invalid delivery found:', delivery);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essayer plusieurs champs de date possibles
|
|
||||||
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
|
|
||||||
const matches = deliveryDate && isSameDay(deliveryDate, date);
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { orders: dayOrders, deliveries: dayDeliveries };
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
||||||
{/* Calendar Header - Design moderne épuré */}
|
{/* Calendar Header - Design moderne épuré */}
|
||||||
@@ -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`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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%' }}>
|
||||||
{children}
|
{/* 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}
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,10 +54,10 @@ 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' ||
|
||||||
query.queryKey[0] === '/api/deliveries'
|
query.queryKey[0] === '/api/deliveries'
|
||||||
|
|||||||
@@ -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)`)
|
||||||
|
|||||||
@@ -10,30 +10,28 @@ export interface ScreenBreakpoints {
|
|||||||
|
|
||||||
const defaultBreakpoints: ScreenBreakpoints = {
|
const defaultBreakpoints: ScreenBreakpoints = {
|
||||||
mobile: 768,
|
mobile: 768,
|
||||||
tablet: 1024,
|
tablet: 1024,
|
||||||
desktop: 1280
|
desktop: 1280
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getScreenSize = (width: number, breakpoints: ScreenBreakpoints): ScreenSize => {
|
||||||
|
if (width < breakpoints.mobile) return 'mobile'
|
||||||
|
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
|
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
|
||||||
const [screenSize, setScreenSize] = useState<ScreenSize>('desktop')
|
// Palier calculé dès le premier rendu (pas de passage transitoire par 'desktop')
|
||||||
const [dimensions, setDimensions] = useState({
|
const [screenSize, setScreenSize] = useState<ScreenSize>(() =>
|
||||||
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
|
typeof window !== 'undefined' ? getScreenSize(window.innerWidth, breakpoints) : 'desktop'
|
||||||
height: typeof window !== 'undefined' ? window.innerHeight : 800
|
)
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getScreenSize = (width: number): ScreenSize => {
|
// Seul le palier est stocké : setScreenSize avec la même valeur ne provoque
|
||||||
if (width < breakpoints.mobile) return 'mobile'
|
// pas de re-rendu, donc l'arbre n'est re-rendu que lorsque le palier change
|
||||||
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
// et non à chaque pixel de redimensionnement
|
||||||
return 'desktop'
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateScreenSize = () => {
|
const updateScreenSize = () => {
|
||||||
const width = window.innerWidth
|
setScreenSize(getScreenSize(window.innerWidth, breakpoints))
|
||||||
const height = window.innerHeight
|
|
||||||
|
|
||||||
setDimensions({ width, height })
|
|
||||||
setScreenSize(getScreenSize(width))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial values
|
// Set initial values
|
||||||
@@ -41,18 +39,25 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
|
|||||||
|
|
||||||
// Add event listener
|
// Add event listener
|
||||||
window.addEventListener('resize', updateScreenSize)
|
window.addEventListener('resize', updateScreenSize)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
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,
|
||||||
isMobile: screenSize === 'mobile',
|
isMobile: screenSize === 'mobile',
|
||||||
isTablet: screenSize === 'tablet',
|
isTablet: screenSize === 'tablet',
|
||||||
isDesktop: screenSize === 'desktop',
|
isDesktop: screenSize === 'desktop',
|
||||||
isMobileOrTablet: screenSize === 'mobile' || screenSize === 'tablet',
|
isMobileOrTablet: screenSize === 'mobile' || screenSize === 'tablet',
|
||||||
isTabletOrDesktop: screenSize === 'tablet' || screenSize === 'desktop'
|
isTabletOrDesktop: screenSize === 'tablet' || screenSize === 'desktop'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 queryClient = useQueryClient();
|
||||||
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
|
const authQuery = useQuery<any>({
|
||||||
if (import.meta.env.DEV) {
|
queryKey: AUTH_QUERY_KEY,
|
||||||
console.log('🔍 Auth Environment Debug:', {
|
queryFn: fetchCurrentUser,
|
||||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
|
staleTime: Infinity,
|
||||||
isDev: import.meta.env.DEV,
|
retry: false,
|
||||||
environment: isDevelopment ? 'development' : 'production'
|
// Après une erreur serveur, ne pas relancer la requête à chaque montage d'un
|
||||||
});
|
// composant (AuthPage) : sinon boucle chargement / page de connexion
|
||||||
}
|
retryOnMount: false,
|
||||||
|
|
||||||
// É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
|
// Rafraîchissement en arrière-plan : tous les composants abonnés reçoivent
|
||||||
const refreshAuth = () => {
|
// le nouvel utilisateur quand la réponse arrive
|
||||||
if (import.meta.env.DEV) {
|
const refreshAuth = useCallback(() => {
|
||||||
console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
|
return queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEY });
|
||||||
}
|
}, [queryClient]);
|
||||||
if (!isDevelopment) {
|
|
||||||
if (import.meta.env.DEV) {
|
// Rechargement immédiat (après connexion) : attend la réponse du serveur,
|
||||||
console.log('🔄 Triggering production auth refresh');
|
// met à jour le cache partagé et renvoie l'utilisateur (ou null)
|
||||||
}
|
const forceAuthRefresh = useCallback(async () => {
|
||||||
setRefreshTrigger(prev => {
|
try {
|
||||||
const newValue = prev + 1;
|
return await queryClient.fetchQuery({
|
||||||
if (import.meta.env.DEV) {
|
queryKey: AUTH_QUERY_KEY,
|
||||||
console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
|
queryFn: fetchCurrentUser,
|
||||||
}
|
staleTime: 0,
|
||||||
return newValue;
|
|
||||||
});
|
});
|
||||||
} else {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) {
|
console.error("Erreur lors du rafraîchissement de l'authentification:", error);
|
||||||
console.log('🔄 Development mode - using React Query refresh');
|
return null;
|
||||||
}
|
|
||||||
developmentQuery.refetch();
|
|
||||||
}
|
}
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const user = authQuery.data ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isLoading: authQuery.isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
error: authQuery.error,
|
||||||
|
refreshAuth,
|
||||||
|
forceAuthRefresh,
|
||||||
|
environment
|
||||||
};
|
};
|
||||||
|
}
|
||||||
// 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();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
|
||||||
|
|
||||||
// Attendre un court délai pour la propagation
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const result = await developmentQuery.refetch();
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Development refetch result:', {
|
|
||||||
success: result.isSuccess,
|
|
||||||
hasData: !!result.data,
|
|
||||||
userId: result.data?.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Authentification production (fetch direct)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDevelopment) return; // Ne pas exécuter en développement
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🔄 Production auth check starting, refreshTrigger:', refreshTrigger);
|
|
||||||
}
|
|
||||||
setProductionLoading(true);
|
|
||||||
|
|
||||||
const response = await fetch('/api/user', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
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 {
|
|
||||||
user: developmentQuery.data || null,
|
|
||||||
isLoading: developmentQuery.isLoading,
|
|
||||||
isAuthenticated: !!developmentQuery.data,
|
|
||||||
error: developmentQuery.error,
|
|
||||||
refreshAuth: refreshAuth,
|
|
||||||
forceAuthRefresh: forceAuthRefresh,
|
|
||||||
environment: 'development'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
user: productionUser,
|
|
||||||
isLoading: productionLoading,
|
|
||||||
isAuthenticated: !!productionUser,
|
|
||||||
error: productionError,
|
|
||||||
refreshAuth: refreshAuth,
|
|
||||||
forceAuthRefresh: forceAuthRefresh,
|
|
||||||
environment: 'production'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 l'entrée Accueil, affichée en tête
|
||||||
|
title?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_SECTIONS: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ path: "/", label: "Accueil", icon: House, 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: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
|
||||||
|
{ 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é";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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 />);
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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é
|
||||||
|
// (avec ou sans magasin sélectionné, bureau ou mobile)
|
||||||
|
const invalidateAvoirs = () => queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const loadCachedVerifications = async () => {
|
const avoirsToVerify = avoirs.filter(avoir =>
|
||||||
console.log('🔍 LoadCache - Début vérification:', {
|
avoir.invoiceReference?.trim() &&
|
||||||
hasAvoirs: !!avoirs,
|
!avoir.nocodbVerified &&
|
||||||
avoirCount: avoirs?.length || 0
|
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
|
||||||
});
|
);
|
||||||
|
if (avoirsToVerify.length === 0) return;
|
||||||
if (!avoirs || avoirs.length === 0) return;
|
|
||||||
|
// Marquage immédiat : un rechargement pendant la vérification ne la double pas
|
||||||
const cachedResults: Record<number, any> = {};
|
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
|
||||||
|
|
||||||
// Charger les vérifications depuis le cache serveur pour TOUS les avoirs avec référence
|
const cachedResults: Record<number, any> = {};
|
||||||
for (const avoir of avoirs) {
|
runVerificationTasks(avoirsToVerify.map(avoir => async () => {
|
||||||
console.log('🔍 LoadCache - Avoir analysé:', {
|
try {
|
||||||
id: avoir.id,
|
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
||||||
hasInvoiceRef: !!avoir.invoiceReference?.trim(),
|
|
||||||
invoiceReference: avoir.invoiceReference,
|
invoiceReference: avoir.invoiceReference,
|
||||||
nocodbVerified: avoir.nocodbVerified
|
forceRefresh: false // Utiliser le cache si disponible
|
||||||
});
|
});
|
||||||
|
if (result?.exists !== undefined) {
|
||||||
// Si l'avoir a une référence de facture, vérifier le cache serveur
|
cachedResults[avoir.id] = result;
|
||||||
if (avoir.invoiceReference?.trim()) {
|
|
||||||
try {
|
|
||||||
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
|
||||||
invoiceReference: avoir.invoiceReference,
|
|
||||||
forceRefresh: false // Utiliser le cache si disponible
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔍 LoadCache - Résultat cache serveur pour avoir', avoir.id, ':', result);
|
|
||||||
|
|
||||||
if (result.exists !== undefined) {
|
|
||||||
cachedResults[avoir.id] = result;
|
|
||||||
console.log('✅ LoadCache - Avoir ajouté au cache depuis serveur:', avoir.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('⚠️ LoadCache - Erreur cache serveur pour avoir', avoir.id, ':', error);
|
|
||||||
// Continuer sans erreur si le cache échoue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Vérification silencieuse : la loupe reste disponible pour réessayer
|
||||||
}
|
}
|
||||||
|
})).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,35 +552,23 @@ 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
|
// Recharger les avoirs pour voir les changements en base (toutes les
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
|
||||||
|
invalidateAvoirs();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Avoir validé",
|
title: "Avoir validé",
|
||||||
description: "L'avoir a été marqué comme validé avec succès",
|
description: "L'avoir a été marqué comme validé avec succès",
|
||||||
@@ -553,12 +595,13 @@ 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
|
||||||
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId);
|
verifiedReferencesRef.current.delete(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é)
|
||||||
|
invalidateAvoirs();
|
||||||
|
|
||||||
// Recharger les avoirs pour voir les changements en base
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Avoir dévalidé",
|
title: "Avoir dévalidé",
|
||||||
description: "L'avoir a été marqué comme non-validé avec succès",
|
description: "L'avoir a été marqué comme non-validé avec succès",
|
||||||
@@ -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 {
|
editAvoirMutation.mutate({
|
||||||
console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount });
|
id: variables.avoirId,
|
||||||
editAvoirMutation.mutate({
|
data: buildAmountUpdate(avoir, result.invoiceAmount),
|
||||||
id: variables.avoirId,
|
});
|
||||||
data: {
|
|
||||||
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)),
|
||||||
|
[avoirs]
|
||||||
|
);
|
||||||
|
const completedAvoirs = useMemo(
|
||||||
|
() => avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified),
|
||||||
|
[avoirs]
|
||||||
|
);
|
||||||
|
|
||||||
// Filtrage par recherche
|
// Filtrage par recherche, différée pour garder la saisie fluide
|
||||||
const filterAvoirs = (avoirsList: Avoir[]) => {
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||||
return avoirsList.filter((avoir) => {
|
const filteredPendingAvoirs = useMemo(
|
||||||
const searchLower = searchTerm.toLowerCase();
|
() => filterAvoirsBySearch(pendingAvoirs, deferredSearchTerm),
|
||||||
return (
|
[pendingAvoirs, deferredSearchTerm]
|
||||||
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
|
);
|
||||||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
|
const filteredCompletedAvoirs = useMemo(
|
||||||
avoir.comment?.toLowerCase().includes(searchLower)
|
() => filterAvoirsBySearch(completedAvoirs, deferredSearchTerm),
|
||||||
);
|
[completedAvoirs, deferredSearchTerm]
|
||||||
});
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const filteredPendingAvoirs = filterAvoirs(pendingAvoirs);
|
|
||||||
const filteredCompletedAvoirs = filterAvoirs(completedAvoirs);
|
|
||||||
|
|
||||||
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 +0,0 @@
|
|||||||
// Backup temporaire pour restaurer si nécessaire
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
<CalendarGrid
|
<div
|
||||||
currentDate={currentDate}
|
className={`transition-opacity ${isMonthLoading ? 'opacity-60' : ''}`}
|
||||||
orders={orders || []}
|
aria-busy={isMonthLoading}
|
||||||
deliveries={deliveries || []}
|
>
|
||||||
publicities={publicities || []}
|
<CalendarGrid
|
||||||
selectedStoreId={selectedStoreId}
|
currentDate={currentDate}
|
||||||
userGroups={user?.userGroups || []}
|
orders={orders}
|
||||||
onDateClick={handleDateClick}
|
deliveries={deliveries}
|
||||||
onItemClick={handleItemClick}
|
publicities={publicities}
|
||||||
/>
|
selectedStoreId={selectedStoreId}
|
||||||
|
userGroups={user?.userGroups || NO_ITEMS}
|
||||||
|
onDateClick={handleDateClick}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,74 +665,67 @@ export default function CustomerOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter orders based on search term, supplier, and status
|
// Filtre et tri recalculés seulement quand la liste ou un critère change
|
||||||
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => {
|
// (et non à chaque rendu, par exemple à chaque frappe dans le commentaire d'appel)
|
||||||
// Search term filter
|
const sortedOrders = useMemo(() => {
|
||||||
const matchesSearch = !searchTerm ||
|
if (!Array.isArray(customerOrders)) return NO_ORDERS;
|
||||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
order.customerPhone.includes(searchTerm) ||
|
|
||||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(order.gencode && order.gencode.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(order.supplier && order.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
|
|
||||||
// Supplier filter
|
|
||||||
const matchesSupplier = filterSupplier === "all" ||
|
|
||||||
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
|
||||||
|
|
||||||
return matchesSearch && matchesSupplier && matchesStatus;
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
// Sort orders - Active orders first, then "Retiré" orders last
|
const search = searchTerm.toLowerCase();
|
||||||
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;
|
|
||||||
|
|
||||||
|
// Filter orders based on search term, supplier, and status
|
||||||
|
const filteredOrders = customerOrders.filter(order => {
|
||||||
|
// Search term filter
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
order.customerName.toLowerCase().includes(search) ||
|
||||||
|
order.productDesignation.toLowerCase().includes(search) ||
|
||||||
|
order.customerPhone.includes(searchTerm) ||
|
||||||
|
(order.productReference && order.productReference.toLowerCase().includes(search)) ||
|
||||||
|
(order.gencode && order.gencode.toLowerCase().includes(search)) ||
|
||||||
|
(order.supplier && order.supplier.name.toLowerCase().includes(search));
|
||||||
|
|
||||||
|
// Supplier filter
|
||||||
|
const matchesSupplier = filterSupplier === "all" ||
|
||||||
|
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
||||||
|
|
||||||
|
return matchesSearch && matchesSupplier && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) }))
|
||||||
// Concaténer : commandes actives d'abord, puis retiré en dernier
|
.sort((a, b) => {
|
||||||
return [...sortedActiveOrders, ...sortedRetiredOrders];
|
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);
|
||||||
|
|
||||||
|
// Sort orders - Active orders first, then "Retiré" orders last
|
||||||
|
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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
.filter((order: any) => order.status === 'pending')
|
if (!Array.isArray(allOrders)) return [];
|
||||||
.sort((a: any, b: any) => {
|
const createdTime = (order: any) => safeDate(order.createdAt)?.getTime() ?? 0;
|
||||||
const dateA = safeDate(a.createdAt);
|
return allOrders
|
||||||
const dateB = safeDate(b.createdAt);
|
.filter((order: any) => order.status === 'pending')
|
||||||
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
|
.sort((a: any, b: any) => createdTime(b) - createdTime(a));
|
||||||
}) : []; // Afficher toutes les commandes en attente
|
}, [allOrders]);
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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) ||
|
||||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
delivery.notes?.toLowerCase().includes(search);
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||||
}) : [];
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [deliveries, searchTerm, statusFilter]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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=…&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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:', {
|
|
||||||
rawNocodbConfigs,
|
|
||||||
rawType: typeof rawNocodbConfigs,
|
|
||||||
nocodbConfigs,
|
|
||||||
configsType: typeof nocodbConfigs,
|
|
||||||
isArray: Array.isArray(rawNocodbConfigs),
|
|
||||||
length: nocodbConfigs.length,
|
|
||||||
environment: window.location.hostname
|
|
||||||
});
|
|
||||||
|
|
||||||
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: groupStats = [] } = useQuery<EntityActivityStats[]>({
|
||||||
|
queryKey: ['/api/stats/by-group'],
|
||||||
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,27 +721,222 @@ export default function Groups() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
{/* Colonne droite : coordonnées + envoi de mails */}
|
||||||
variant="outline"
|
<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
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
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
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
@@ -61,21 +61,6 @@ export default function NocoDBConfig() {
|
|||||||
// Protection quadruple couche pour éviter les erreurs TypeError
|
// Protection quadruple couche pour éviter les erreurs TypeError
|
||||||
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({
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) ||
|
||||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
order.notes?.toLowerCase().includes(search);
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||||
}) : [];
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [orders, searchTerm, statusFilter]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -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,10 +257,50 @@ 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">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
{header}
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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:', {
|
|
||||||
userRole: user.role,
|
|
||||||
userUsername: user.username,
|
|
||||||
allUserGroups: userGroups,
|
|
||||||
filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name }))
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredGroups;
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableGroups = getUserGroups();
|
// Filter tickets by search term
|
||||||
|
const filteredTickets = useMemo(() => {
|
||||||
|
if (!searchTerm) return ticketsData;
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return ticketsData.filter(ticket => (
|
||||||
|
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)
|
||||||
|
));
|
||||||
|
}, [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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +115,11 @@ 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);
|
||||||
toast({
|
toast({
|
||||||
@@ -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,39 +315,17 @@ 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'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -372,38 +333,40 @@ export default function Tasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data.filter(task => task && typeof task === 'object' && task.id);
|
return data.filter(task => task && typeof task === 'object' && task.id);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tasks:', error);
|
console.error('Error fetching tasks:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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,86 +419,117 @@ 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);
|
||||||
.filter((task: TaskWithRelations) => {
|
};
|
||||||
// Filtre par recherche
|
|
||||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
|
||||||
!task.description?.toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par statut
|
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
setTaskToDelete(task);
|
||||||
return false;
|
setShowDeleteModal(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Filtre par priorité
|
const handleConfirmDelete = () => {
|
||||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
if (!taskToDelete) return;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par date d'échéance
|
deleteTaskMutation.mutate(taskToDelete.id);
|
||||||
if (dueDateFilter !== "all" && task.dueDate) {
|
setShowDeleteModal(false);
|
||||||
const dueDate = new Date(task.dueDate);
|
setTaskToDelete(null);
|
||||||
switch (dueDateFilter) {
|
};
|
||||||
case "today":
|
|
||||||
if (!isToday(dueDate)) return false;
|
// La recherche est différée : la saisie reste fluide pendant le filtrage
|
||||||
break;
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||||
case "this_week":
|
|
||||||
if (!isThisWeek(dueDate)) return false;
|
// Filtrer et trier les tâches (recalculé seulement quand la liste ou un filtre change)
|
||||||
break;
|
const filteredTasks = useMemo(() => {
|
||||||
case "overdue":
|
const search = deferredSearchTerm.toLowerCase();
|
||||||
if (!isPast(dueDate) || task.status === 'completed') return false;
|
return tasks
|
||||||
break;
|
.filter((task: TaskWithRelations) => {
|
||||||
case "no_due_date":
|
// Filtre par recherche
|
||||||
if (task.dueDate) return false;
|
if (search && !task.title.toLowerCase().includes(search) &&
|
||||||
break;
|
!task.description?.toLowerCase().includes(search)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} else if (dueDateFilter === "no_due_date" && task.dueDate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
// Filtre par statut
|
||||||
})
|
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
return false;
|
||||||
// Faire remonter les tâches non validées (pending) en premier
|
}
|
||||||
if (a.status === 'pending' && b.status === 'completed') return -1;
|
|
||||||
if (a.status === 'completed' && b.status === 'pending') return 1;
|
// Filtre par priorité
|
||||||
|
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par date d'échéance
|
||||||
|
if (dueDateFilter !== "all" && task.dueDate) {
|
||||||
|
const dueDate = new Date(task.dueDate);
|
||||||
|
switch (dueDateFilter) {
|
||||||
|
case "today":
|
||||||
|
if (!isToday(dueDate)) return false;
|
||||||
|
break;
|
||||||
|
case "this_week":
|
||||||
|
if (!isThisWeek(dueDate)) return false;
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
if (!isPast(dueDate) || task.status === 'completed') return false;
|
||||||
|
break;
|
||||||
|
case "no_due_date":
|
||||||
|
if (task.dueDate) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (dueDateFilter === "no_due_date" && task.dueDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||||
|
// Faire remonter les tâches non validées (pending) en premier
|
||||||
|
if (a.status === 'pending' && b.status === 'completed') return -1;
|
||||||
|
if (a.status === 'completed' && b.status === 'pending') return 1;
|
||||||
|
|
||||||
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
||||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
if (aPriority !== bPriority) {
|
||||||
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enfin, trier par date de création (plus récent en premier)
|
// Enfin, trier par date de création (plus récent en premier)
|
||||||
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
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 && (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,11 +132,16 @@ 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) {
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,25 +102,45 @@ 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',
|
||||||
const events: CalendarEvent[] = [
|
// heure locale comme isSameDay) une seule fois par chargement au lieu d'un
|
||||||
...orders.map((order: any) => ({
|
// filtrage complet pour chaque case du calendrier
|
||||||
id: order.id,
|
const { events, eventsByDay } = useMemo(() => {
|
||||||
title: `Cmd #${order.orderNumber || order.id}`,
|
const events: CalendarEvent[] = [
|
||||||
date: order.plannedDate,
|
...orders.map((order: any) => ({
|
||||||
type: 'order' as const,
|
id: order.id,
|
||||||
status: order.status,
|
title: `Cmd #${order.orderNumber || order.id}`,
|
||||||
supplier: order.supplier
|
date: order.plannedDate,
|
||||||
})),
|
type: 'order' as const,
|
||||||
...deliveries.map((delivery: any) => ({
|
status: order.status,
|
||||||
id: delivery.id,
|
supplier: order.supplier
|
||||||
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
})),
|
||||||
date: delivery.scheduledDate,
|
...deliveries.map((delivery: any) => ({
|
||||||
type: 'delivery' as const,
|
id: delivery.id,
|
||||||
status: delivery.status,
|
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
||||||
supplier: delivery.supplier
|
date: delivery.scheduledDate,
|
||||||
}))
|
type: 'delivery' as const,
|
||||||
];
|
status: delivery.status,
|
||||||
|
supplier: delivery.supplier
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}>
|
||||||
{children}
|
<Suspense fallback={<MobilePageFallback />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
<>
|
<nav
|
||||||
{/* Navigation bottom fixe */}
|
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40"
|
||||||
<nav
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50"
|
aria-label="Navigation principale"
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
>
|
||||||
>
|
<div className="flex items-stretch h-16">
|
||||||
<div className="flex items-center justify-around h-16">
|
{shortcuts.map((item) => {
|
||||||
{/* Items principaux */}
|
const Icon = item.icon;
|
||||||
{primaryItems.map((item) => {
|
const active = isNavItemActive(item.path, location);
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.path} href={item.path}>
|
<Link
|
||||||
<div className={`flex flex-col items-center justify-center min-w-[60px] py-1 ${active ? 'text-blue-600' : 'text-gray-500'
|
key={item.path}
|
||||||
}`}>
|
href={item.path}
|
||||||
<Icon className={`h-5 w-5 ${active ? 'text-blue-600' : 'text-gray-500'}`} />
|
aria-current={active ? "page" : undefined}
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${active ? 'text-blue-600' : 'text-gray-500'
|
className={itemClasses(active)}
|
||||||
}`}>
|
>
|
||||||
{item.label}
|
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
</span>
|
<span className="line-clamp-2">{item.label}</span>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bouton "Plus" */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className={`flex flex-col items-center justify-center min-w-[60px] py-1 cursor-pointer ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
onClick={onOpenMenu}
|
||||||
}`}
|
aria-haspopup="dialog"
|
||||||
onClick={() => setMoreOpen(true)}
|
aria-expanded={menuOpen}
|
||||||
>
|
className={itemClasses(menuActive)}
|
||||||
<MoreHorizontal className={`h-5 w-5 ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
>
|
||||||
}`} />
|
<Menu className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
<span>Menu</span>
|
||||||
}`}>
|
</button>
|
||||||
Plus
|
</div>
|
||||||
</span>
|
</nav>
|
||||||
</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 (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
href={item.path}
|
|
||||||
onClick={() => setMoreOpen(false)}
|
|
||||||
>
|
|
||||||
<div className={`flex flex-col items-center justify-center p-4 rounded-xl min-h-[80px] ${active
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: '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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<p className="text-sm text-gray-500 capitalize">{user.role}</p>
|
{section.items.map((item) => {
|
||||||
</div>
|
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,15 +194,22 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Déconnexion */}
|
{user && (
|
||||||
<Button
|
<div className="flex items-center justify-between gap-3">
|
||||||
variant="outline"
|
<div className="min-w-0">
|
||||||
className="w-full h-12 text-red-600 border-red-200 hover:bg-red-50"
|
<p className="font-medium text-gray-900 truncate">{displayName}</p>
|
||||||
onClick={handleLogout}
|
<p className="text-sm text-gray-600">{getRoleLabel(user.role)}</p>
|
||||||
>
|
</div>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<Button
|
||||||
Déconnexion
|
variant="outline"
|
||||||
</Button>
|
className="h-11 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -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,55 +313,113 @@ 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(() => {
|
||||||
.filter((task: TaskWithRelations) => {
|
const search = searchTerm.toLowerCase();
|
||||||
// Tab filter
|
return tasks
|
||||||
if (activeTab === 'pending' && task.status === 'completed') return false;
|
.filter((task: TaskWithRelations) => {
|
||||||
if (activeTab === 'completed' && task.status !== 'completed') return false;
|
// Tab filter
|
||||||
|
if (activeTab === 'pending' && 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;
|
||||||
})
|
})
|
||||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||||
// Pending first, then by priority
|
// Pending first, then by priority
|
||||||
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
||||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||||
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' });
|
setDeletingTask(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
|
||||||
toast({ title: "🗑️ Tâche supprimée" });
|
|
||||||
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" />
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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%';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
|
|
||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: LogiFlow2025!
|
POSTGRES_PASSWORD: LogiFlow2025!
|
||||||
POSTGRES_DB: logiflow_db
|
POSTGRES_DB: logiflow_db
|
||||||
BACKUP_DIR: /app/backups
|
BACKUP_DIR: /app/backups
|
||||||
|
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||||
|
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# API externe — Rapprochement BL / factures
|
||||||
|
|
||||||
|
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||||
|
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||||
|
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||||
|
|
||||||
|
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||||
|
|
||||||
|
## Activation et authentification
|
||||||
|
|
||||||
|
**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
|
||||||
|
crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
|
||||||
|
fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
|
||||||
|
d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
|
||||||
|
(table `external_api_keys`). L'onglet s'ouvre directement par
|
||||||
|
`/utilities?tab=api`.
|
||||||
|
|
||||||
|
**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
|
||||||
|
séparées par des virgules, puis le serveur redémarré :
|
||||||
|
|
||||||
|
```
|
||||||
|
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||||
|
```
|
||||||
|
|
||||||
|
Générer une clé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
|
||||||
|
`503` (désactivée).
|
||||||
|
|
||||||
|
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: <clé>
|
||||||
|
Authorization: Bearer <clé>
|
||||||
|
```
|
||||||
|
|
||||||
|
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||||
|
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||||
|
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||||
|
|
||||||
|
## Points d'accès
|
||||||
|
|
||||||
|
### `GET /stores` — magasins
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /suppliers` — fournisseurs
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` = code fournisseur (`codefou`).
|
||||||
|
|
||||||
|
### `GET /deliveries` — livraisons à rapprocher
|
||||||
|
|
||||||
|
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||||
|
Rapprochement), de la plus récente à la plus ancienne.
|
||||||
|
|
||||||
|
| Paramètre | Exemple | Effet |
|
||||||
|
|--------------|---------------|------------------------------------------------------------|
|
||||||
|
| `storeId` | `1` | Un seul magasin |
|
||||||
|
| `supplierId` | `4` | Un seul fournisseur |
|
||||||
|
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||||
|
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||||
|
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||||
|
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||||
|
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||||
|
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||||
|
| `offset` | `0` | Décalage de pagination |
|
||||||
|
|
||||||
|
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 812,
|
||||||
|
"storeId": 1,
|
||||||
|
"storeName": "Magasin Nancy",
|
||||||
|
"supplierId": 4,
|
||||||
|
"supplierName": "Lactalis",
|
||||||
|
"supplierCode": "LAC01",
|
||||||
|
"automaticReconciliation": false,
|
||||||
|
"status": "delivered",
|
||||||
|
"scheduledDate": "2026-10-08",
|
||||||
|
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||||
|
"blNumber": "BL-4567",
|
||||||
|
"blAmount": 1234.5,
|
||||||
|
"invoiceReference": null,
|
||||||
|
"invoiceAmount": null,
|
||||||
|
"invoiceAmountTTC": null,
|
||||||
|
"dueDate": null,
|
||||||
|
"reconciled": false,
|
||||||
|
"validatedAt": null,
|
||||||
|
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||||
|
|
||||||
|
### `GET /deliveries/:id` — une livraison
|
||||||
|
|
||||||
|
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||||
|
|
||||||
|
### `PATCH /deliveries/:id` — écrire la facture
|
||||||
|
|
||||||
|
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||||
|
|
||||||
|
| Champ | Type | Effet |
|
||||||
|
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||||
|
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||||
|
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||||
|
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||||
|
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||||
|
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Réponse : la livraison mise à jour.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- livraison non livrée : `409` ;
|
||||||
|
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||||
|
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||||
|
- exception, fournisseur en **rapprochement automatique**
|
||||||
|
(`automaticReconciliation: true`) : la livraison est validée d'office dès la
|
||||||
|
saisie du BL, sa facture (référence, montants, échéance) se complète ensuite
|
||||||
|
directement, sans la dévalider ; elle reste validée ;
|
||||||
|
- corps invalide : `400` avec le détail par champ.
|
||||||
|
|
||||||
|
## Exemple : rapprocher une facture reçue
|
||||||
|
|
||||||
|
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||||
|
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||||
|
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||||
|
(ajouter `"reconciled": true` pour valider directement).
|
||||||
|
|
||||||
|
## Exemple : compléter la facture d'un fournisseur automatique
|
||||||
|
|
||||||
|
Les livraisons d'un fournisseur en rapprochement automatique sont déjà
|
||||||
|
validées (`reconciled: true`) ; la facture s'y ajoute sans autre champ :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/815 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0057","invoiceAmount":"842,10","invoiceAmountTTC":"1010,52","dueDate":"2026-11-30"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les retrouver : `GET /deliveries?supplierId=<id auto>&hasInvoice=false`.
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
# Tableau de bord
|
||||||
|
|
||||||
|
_46 constats vérifiés — 9 haute, 23 moyenne, 14 basse._
|
||||||
|
|
||||||
|
## Pages analysées
|
||||||
|
|
||||||
|
### `/ et /dashboard (desktop, largeur >= 768px)`
|
||||||
|
|
||||||
|
**Rôle :** Page d'accueil après connexion. Elle donne une vue d'ensemble du magasin : alertes DLC, 4 compteurs (livraisons du mois, commandes en attente, délai moyen, palettes), commandes en attente, livraisons prévues, publicités à venir, informations internes et tâches à faire.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir ce qu'il faut traiter aujourd'hui (produits DLC expirés ou proches, commandes en retard, livraisons attendues)
|
||||||
|
- Lire les informations / annonces publiées par l'administration
|
||||||
|
- (Admin) créer, modifier ou supprimer une information
|
||||||
|
- Marquer un produit DLC comme 'stock épuisé' depuis la fenêtre d'alerte, ou reporter l'alerte de 2 h
|
||||||
|
- Consulter les tâches ouvertes du magasin
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/user (x5 : RouterProduction, Layout, Sidebar via useAuthSimple, Dashboard, AnnouncementCard) | au montage de chaque composant (fetch dans un useEffect, cache: 'no-cache', non partagé) | server/localAuth.ts:204 (dev) / server/localAuth.production.ts:246 ; req.user vient de deserializeUser -> storage.getUserWithGroups (storage.ts:371) | 5 appels identiques. En production, chaque instance démarre avec user=null, ce qui fausse les requêtes du Dashboard lancées au montage. |
|
||||||
|
| GET /api/groups | au montage du Layout (enabled: !!user) | server/routes.ts:970 -> storage.getUserWithGroups + storage.getGroups (storage.ts:432) | Renvoie toutes les colonnes de groups (logo base64, config NocoDB/SMTP). |
|
||||||
|
| GET /api/weather/current | au montage du WeatherWidget (en-tête), puis refetchInterval de 30 min | server/routes.ts:5671 -> getWeatherSettings (storage.ts:2891), getWeatherData x2 (storage.ts:2910), puis API Visual Crossing si la donnée manque | Lectures BD et appels externes séquentiels, sans timeout. Rien n'est mis en cache quand l'historique N-1 échoue. |
|
||||||
|
| GET /api/stats/monthly?year&month[&storeId si admin] | au montage | server/routes.ts:4155 -> storage.getMonthlyStats (storage.ts:1222), 4 requêtes séquentielles | Résultat 'stats' jamais affiché : requête inutile. |
|
||||||
|
| GET /api/stats/yearly?year[&storeId si admin] | au montage | server/routes.ts:4192 -> storage.getYearlyStats (storage.ts:1364), 4 requêtes séquentielles | Seul averageDeliveryTime est utilisé. Au montage, la requête part sans storeId (user=null) et le résultat reste en cache. |
|
||||||
|
| GET /api/orders[?storeId si admin] | au montage, puis une 2e fois quand user est chargé (admin avec magasin sélectionné : la clé change) | server/routes.ts:1343 -> storage.getOrders (storage.ts:678) + loadDeliveriesByOrderIds (storage.ts:522) | Tout l'historique, sans limite, avec les livraisons imbriquées et les objets supplier/group complets. |
|
||||||
|
| GET /api/deliveries[?storeId si admin] | au montage, puis une 2e fois (même cas que /api/orders) | server/routes.ts:1752 -> storage.getDeliveries (storage.ts:885) + attachOrdersAndCommentCounts | Tout l'historique, alors que la page n'affiche que 2 compteurs et 4 lignes. |
|
||||||
|
| GET /api/customer-orders[?storeId si admin] | au montage | server/routes.ts:3486 -> storage.getCustomerOrders (storage.ts:1868) | customerOrderStats est calculé mais jamais affiché : requête inutile. |
|
||||||
|
| GET /api/announcements?recent=true[&storeId] | au montage, après le /api/user propre au Dashboard (enabled: !!user) | server/routes.ts:5770 (requête Drizzle inline + N+1 sur users/groups, l. 5815-5866) | Paramètre 'recent' ignoré par le serveur. Filtre de 2 jours fait côté client. |
|
||||||
|
| GET /api/announcements[?storeId] | au montage d'AnnouncementCard, après son propre /api/user | server/routes.ts:5770 | Doublon de l'appel précédent sous une autre queryKey. |
|
||||||
|
| GET /api/ad-campaigns?year=N-1, N, N+1 | au montage, 3 appels séquentiels (boucle for + await) | server/routes.ts:4595 -> storage.getPublicities (storage.ts:1502) | Télécharge 3 années complètes pour en garder 3 lignes ; groupIds est ignoré côté storage. |
|
||||||
|
| GET /api/dlc-products/stats?storeId | au montage, plus une invalidation forcée à chaque montage (useEffect) | server/routes.ts:2831 -> storage.getDlcStats (storage.ts:2224) | Agrégat SQL correct, mais définitions incohérentes avec la liste. |
|
||||||
|
| GET /api/dlc-products?status=expires\|expires_soon&storeId | à l'ouverture de la modale DLC (enabled: isOpen && compteur > 0) | server/routes.ts:2797 -> storage.getDlcProducts (storage.ts:1976) | Liste complète sans LIMIT alors que 8 lignes sont affichées. |
|
||||||
|
| GET /api/tasks[?storeId si admin] | au montage | server/routes.ts:3193 -> storage.getTasks (storage.ts:2267) | Renvoie [] pour tout non-admin sans storeId (routes.ts:3237-3249). Historique complet, tâches terminées comprises. |
|
||||||
|
| PUT /api/dlc-products/:id/stock-epuise | au clic sur 'Stock épuisé' dans la modale DLC | server/routes.ts:3023 | Invalide /api/dlc-products et /api/dlc-products/stats. |
|
||||||
|
| POST/PUT/DELETE /api/announcements[/:id] | au clic (admin) dans AnnouncementCard | server/routes.ts:5895 / 5976 / 6027 | La suppression part sans confirmation. L'invalidation ['/api/announcements'] ne touche pas la clé '/api/announcements/recent'. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Page dense : 2 bandeaux DLC, 4 compteurs, 5 cartes et jusqu'à 2 modales automatiques. Rien n'est cliquable : ni compteurs, ni lignes, ni bandeaux, alors que les cartes ont un effet d'ombre au survol. Il n'y a ni état de chargement ni état d'erreur : pendant le chargement, l'utilisateur voit '0', 'Aucune commande en attente' ou 'Toutes les tâches sont terminées', ce qui est faux. Du texte de debug est visible ('(API: NOT_ARRAY)', '(N livraisons totales)'). Certains libellés sont ambigus ('Total palettes', délai calculé sur l'année sans le dire, numéro de publicité affiché à la place de sa désignation). Le badge de jours en attente peut être négatif. Les badges de priorité ont tous la même couleur. La bordure colorée des lignes est invisible (classe border-l-3 inexistante). La modale DLC ne peut pas se fermer avec la croix : elle se rouvre aussitôt. Elle peut aussi s'empiler sur la modale d'annonce. Pour les managers et directeurs, les données ne suivent pas le magasin sélectionné : tâches toujours vides, commandes du 1er magasin, statistiques agrégées sur tous leurs magasins.
|
||||||
|
|
||||||
|
### `/ et /dashboard (mobile, largeur < 768px)`
|
||||||
|
|
||||||
|
**Rôle :** Accueil simplifié sur téléphone : salutation et date, 4 compteurs (commandes, livraisons, tâches en cours, tâches urgentes), accès rapides et 3 tâches à faire.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir d'un coup d'œil l'activité du jour
|
||||||
|
- Aller vite aux commandes, livraisons, tâches ou au calendrier
|
||||||
|
- Voir les prochaines tâches à faire
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/user (x5 : RouterProduction, MobileApp, DashboardPage, MobileLayout, MobileBottomNav) | au montage, puis 3 de plus à chaque navigation (MobileLayout et MobileBottomNav sont remontés par chaque page) | server/localAuth.ts:204 / localAuth.production.ts:246 -> getUserWithGroups | Les requêtes de la page attendent le /api/user de leur propre instance (enabled: !!user) : une cascade de plus. |
|
||||||
|
| GET /api/groups | au montage de MobileApp | server/routes.ts:970 | OK |
|
||||||
|
| GET /api/stats/dashboard?storeId | au montage | aucun : la route n'existe pas dans server/routes.ts, réponse 404 | Appel mort ; 'stats' et 'isLoading' ne sont jamais utilisés. |
|
||||||
|
| GET /api/tasks?storeId | au montage | server/routes.ts:3193 -> storage.getTasks (storage.ts:2267) | Même queryKey ['/api/tasks', storeId] que le Dashboard desktop et la page Tâches, mais pas la même URL. |
|
||||||
|
| GET /api/orders?storeId&date=yyyy-MM-dd | au montage | server/routes.ts:1343 (ne lit que startDate/endDate, l. 1351) -> getOrders (storage.ts:678) | Paramètre 'date' ignoré : tout l'historique est téléchargé et affiché comme 'Commandes'. |
|
||||||
|
| GET /api/deliveries?storeId&date=yyyy-MM-dd | au montage | server/routes.ts:1752 (date ignorée) -> getDeliveries (storage.ts:885) | Idem : total historique affiché comme 'Livraisons'. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Écran simple et lisible : salutation, grosses tuiles, accès rapides. Mais les chiffres 'Commandes' et 'Livraisons' sont des totaux historiques présentés comme l'activité du jour. Il n'y a pas d'état de chargement : '0' s'affiche d'abord. Il n'y a aucune alerte DLC ni information, contrairement au desktop, alors que les employés sur téléphone en ont besoin. Les accès rapides reprennent 3 des 4 boutons de la barre du bas. Leurs libellés au singulier ('Commande', 'Tâche') laissent croire à une création. 'Agenda' s'appelle 'Calendrier' dans la barre du bas. Les liens <a href> rechargent toute l'application.
|
||||||
|
|
||||||
|
### `En-tête desktop (toutes les routes) : WeatherWidget + DateWidget`
|
||||||
|
|
||||||
|
**Rôle :** Affiche dans l'en-tête la ville, la météo du jour comparée à la même date l'an dernier, et la date du jour.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Comparer la météo du jour à celle de l'an dernier (prévision d'affluence)
|
||||||
|
- Voir la date du jour
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/weather/current | au montage du Layout, puis polling toutes les 30 min ; retry: 1 | server/routes.ts:5671 -> getWeatherSettings / getWeatherData / getNearestWeatherData (storage.ts:2891-2941) + weatherService.fetchCurrentWeather / fetchPreviousYearWeather (weatherService.ts:24, 45) | Répond 404 si le service n'est pas configuré, ce qui déclenche un retry inutile. Appels BD et externes séquentiels, sans timeout. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Bloc compact, mais 3 colonnes (Aujourd'hui / Année dernière / Différence) prennent beaucoup de place dans l'en-tête. Le texte 'Chargement météo...' apparaît puis disparaît si le service n'est pas configuré, ce qui décale l'en-tête. La date est calculée au rendu et n'est jamais rafraîchie après minuit. La traduction des conditions météo est faite deux fois (client et serveur).
|
||||||
|
|
||||||
|
### `/calendar (StatsPanel)`
|
||||||
|
|
||||||
|
**Rôle :** Panneau flottant 'Statistiques du mois' sur le calendrier : commandes, livraisons, palettes, colis, délai moyen.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir les volumes du mois affiché dans le calendrier
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/stats/monthly?year&month&storeId | au montage et à chaque changement de mois | server/routes.ts:4155 -> storage.getMonthlyStats (storage.ts:1222) | queryKey [statsUrl, storeId] différente de celle du Dashboard ['/api/stats/monthly', storeId] pour la même donnée. Sans placeholderData, un squelette s'affiche à chaque changement de mois. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Panneau fixé en bas à droite (min-w-80), qu'on ne peut pas replier : il masque une partie du calendrier, surtout sur tablette. Les valeurs 'Palettes' et 'Colis' sont fausses (somme de toutes les unités, et nombre de livraisons). 'Commandes en attente' est un total historique affiché sous le titre 'Statistiques du mois'.
|
||||||
|
|
||||||
|
### `(non routé) RecentTasksCard, ResponsiveDashboard`
|
||||||
|
|
||||||
|
**Rôle :** Composants prévus pour le tableau de bord mais importés nulle part : code mort.
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/tasks?storeId | jamais : le composant n'est pas monté | server/routes.ts:3193 | fetch sans credentials ni contrôle res.ok. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Pas visible par l'utilisateur. Il entretient seulement de la confusion pour les développeurs : troisième tri et troisième mise en forme des priorités de tâches.
|
||||||
|
|
||||||
|
## Constats
|
||||||
|
|
||||||
|
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| [DASH-01](#dash-01) | haute | bug | S | Pour les non-admins, le magasin sélectionné n'est pas envoyé : tâches toujours vides, données d'un autre magasin | `client/src/pages/Dashboard.tsx:285` |
|
||||||
|
| [DASH-02](#dash-02) | haute | bug | S | La fenêtre d'alerte DLC se rouvre aussitôt qu'on la ferme (croix, Échap, clic à l'extérieur) | `client/src/pages/Dashboard.tsx:273` |
|
||||||
|
| [DASH-03](#dash-03) | haute | bug | M | En production, user vaut null au montage du Dashboard : requêtes envoyées avec le mauvais périmètre puis téléchargées deux fois | `client/src/hooks/useAuthUnified.ts:23` |
|
||||||
|
| [DASH-05](#dash-05) | haute | perf-api | L | L'historique complet des commandes et livraisons est téléchargé pour afficher 4 compteurs et 2 courtes listes | `client/src/pages/Dashboard.tsx:84` |
|
||||||
|
| [DASH-07](#dash-07) | haute | perf-serveur | M | Chaque ligne embarque l'objet magasin complet, logo base64 et configuration NocoDB/SMTP compris | `server/storage.ts:693` |
|
||||||
|
| [DASH-08](#dash-08) | haute | perf-serveur | S | Pas de compression HTTP ni de cache long sur les assets, et le middleware de compression maison est faux et inutilisé | `server/index.production.ts:143` |
|
||||||
|
| [DASH-22](#dash-22) | haute | ux-simplicite | M | Aucun état de chargement ni d'erreur : l'écran affiche de fausses informations pendant le chargement | `client/src/pages/Dashboard.tsx:435` |
|
||||||
|
| [DASH-28](#dash-28) | haute | bug | S | Mobile : 'Commandes' et 'Livraisons' affichent des totaux historiques (paramètre date ignoré), et un appel part vers une route inexistante | `client/src/pages/mobile/DashboardPage.tsx:101` |
|
||||||
|
| [DASH-32](#dash-32) | haute | perf-bundle | M | Aucun découpage du code : les 30+ pages desktop et mobile et recharts sont chargées dès l'accueil | `client/src/components/RouterProduction.tsx:5` |
|
||||||
|
| [DASH-04](#dash-04) | moyenne | perf-api | M | /api/user appelé 5 fois au chargement du tableau de bord, et 3 fois de plus à chaque navigation mobile | `client/src/hooks/useAuthUnified.ts:160` |
|
||||||
|
| [DASH-06](#dash-06) | moyenne | perf-api | S | Deux requêtes dont le résultat n'est jamais affiché : /api/stats/monthly et /api/customer-orders | `client/src/pages/Dashboard.tsx:28` |
|
||||||
|
| [DASH-09](#dash-09) | moyenne | perf-serveur | M | Chaque route relit l'utilisateur et ses magasins (2 requêtes) déjà chargés par passport | `server/routes.ts:1345` |
|
||||||
|
| [DASH-10](#dash-10) | moyenne | perf-serveur | S | Statistiques mensuelles et annuelles : 4 requêtes SQL séquentielles, non mises en cache | `server/storage.ts:1270` |
|
||||||
|
| [DASH-11](#dash-11) | moyenne | perf-api | S | Publicités à venir : 3 appels séquentiels qui téléchargent 3 années complètes pour en afficher 3 | `client/src/pages/Dashboard.tsx:205` |
|
||||||
|
| [DASH-12](#dash-12) | moyenne | perf-serveur | S | Informations : requête N+1 (auteur et magasin chargés un par un) et contenu complet écrit dans les logs | `server/routes.ts:5815` |
|
||||||
|
| [DASH-13](#dash-13) | moyenne | bug | S | Les non-admins voient les informations de tous les magasins, et le paramètre 'recent' est ignoré | `server/routes.ts:5797` |
|
||||||
|
| [DASH-15](#dash-15) | moyenne | perf-client | S | Changer de magasin relance d'abord les requêtes de l'ancien magasin, puis affiche des zéros | `client/src/components/Layout.tsx:138` |
|
||||||
|
| [DASH-17](#dash-17) | moyenne | perf-client | S | Calculs lourds refaits à chaque rendu, sans useMemo, et tri qui modifie le cache React Query | `client/src/pages/Dashboard.tsx:293` |
|
||||||
|
| [DASH-18](#dash-18) | moyenne | bug | S | Le badge 'jours en attente' affiche des valeurs négatives et n'a pas de légende | `client/src/pages/Dashboard.tsx:506` |
|
||||||
|
| [DASH-19](#dash-19) | moyenne | coherence-design | S | Badges de priorité des tâches : le variant est passé dans className, donc toutes les priorités ont la même couleur | `client/src/pages/Dashboard.tsx:687` |
|
||||||
|
| [DASH-21](#dash-21) | moyenne | lisibilite | S | Texte de debug technique visible par les utilisateurs | `client/src/pages/Dashboard.tsx:642` |
|
||||||
|
| [DASH-23](#dash-23) | moyenne | ux-simplicite | M | Rien n'est cliquable alors que tout réagit au survol | `client/src/pages/Dashboard.tsx:430` |
|
||||||
|
| [DASH-24](#dash-24) | moyenne | ux-simplicite | M | Alertes DLC affichées en double (bandeaux + fenêtre), et deux fenêtres automatiques peuvent s'empiler | `client/src/pages/Dashboard.tsx:409` |
|
||||||
|
| [DASH-25](#dash-25) | moyenne | lisibilite | S | Libellés ambigus, majuscules à l'anglaise, et numéro de publicité affiché à la place de la désignation | `client/src/pages/Dashboard.tsx:476` |
|
||||||
|
| [DASH-26](#dash-26) | moyenne | bug | S | Statistiques 'Palettes' et 'Colis' fausses : unités mélangées et nombre de livraisons présenté comme nombre de colis | `server/storage.ts:1310` |
|
||||||
|
| [DASH-27](#dash-27) | moyenne | bug | S | Compteurs DLC incohérents avec la liste de la fenêtre : produits du jour comptés deux fois, 'stock épuisé' traités différemment | `server/storage.ts:2245` |
|
||||||
|
| [DASH-29](#dash-29) | moyenne | perf-client | M | Chaque page mobile remonte l'en-tête et la barre du bas, ce qui relance /api/user à chaque navigation | `client/src/pages/mobile/DashboardPage.tsx:136` |
|
||||||
|
| [DASH-30](#dash-30) | moyenne | perf-client | S | Les liens 'Accès rapide' et le bouton de la fenêtre DLC rechargent toute l'application | `client/src/pages/mobile/DashboardPage.tsx:187` |
|
||||||
|
| [DASH-31](#dash-31) | moyenne | ux-simplicite | M | Mobile : les accès rapides doublonnent la barre du bas, avec des libellés trompeurs, et les alertes utiles manquent | `client/src/pages/mobile/DashboardPage.tsx:179` |
|
||||||
|
| [DASH-35](#dash-35) | moyenne | perf-serveur | S | Météo : appels séquentiels sans timeout, et l'API externe est rappelée à chaque requête si l'historique échoue | `server/routes.ts:5683` |
|
||||||
|
| [DASH-37](#dash-37) | moyenne | ux-simplicite | S | Informations : suppression sans confirmation avec une icône 'X', boutons de 24 px sans libellé, texte non tronqué | `client/src/components/AnnouncementCard.tsx:356` |
|
||||||
|
| [DASH-38](#dash-38) | moyenne | bug | M | Mêmes queryKeys pour des URL différentes (et l'inverse) : les données affichées dépendent de la page visitée en premier | `client/src/pages/Dashboard.tsx:282` |
|
||||||
|
| [DASH-14](#dash-14) | basse | perf-api | S | Les informations sont récupérées deux fois sous deux clés différentes, qui ne s'invalident pas ensemble | `client/src/pages/Dashboard.tsx:97` |
|
||||||
|
| [DASH-16](#dash-16) | basse | perf-client | S | Les requêtes DLC sont invalidées à chaque montage du tableau de bord, ce qui annule le cache | `client/src/pages/Dashboard.tsx:23` |
|
||||||
|
| [DASH-20](#dash-20) | basse | coherence-design | S | La classe 'border-l-3' n'existe pas dans Tailwind 3 : la barre colorée à gauche des lignes est invisible | `client/src/pages/Dashboard.tsx:510` |
|
||||||
|
| [DASH-33](#dash-33) | basse | perf-client | S | Script de bannière Replit (domaine tiers) chargé en production | `client/index.html:19` |
|
||||||
|
| [DASH-34](#dash-34) | basse | accessibilite | S | Page déclarée en anglais et zoom bloqué sur mobile | `client/index.html:5` |
|
||||||
|
| [DASH-36](#dash-36) | basse | ux-simplicite | S | Météo non configurée : retry inutile puis disparition du bloc, avec décalage de l'en-tête | `client/src/components/WeatherWidget.tsx:121` |
|
||||||
|
| [DASH-39](#dash-39) | basse | lisibilite | S | Fenêtre DLC : formulations techniques ou alarmistes, et '0 jour(s)' | `client/src/components/DlcAlertModal.tsx:178` |
|
||||||
|
| [DASH-40](#dash-40) | basse | ux-simplicite | S | 'Livraisons à venir' inclut les livraisons en retard, et les tâches sont triées différemment sur chaque écran | `client/src/pages/Dashboard.tsx:310` |
|
||||||
|
| [DASH-41](#dash-41) | basse | accessibilite | S | Textes secondaires en gris clair 'text-gray-400' de petite taille, sous le seuil de contraste | `client/src/pages/Dashboard.tsx:568` |
|
||||||
|
| [DASH-42](#dash-42) | basse | ux-simplicite | S | Panneau de statistiques fixe et non repliable qui masque le calendrier | `client/src/components/StatsPanel.tsx:54` |
|
||||||
|
| [DASH-43](#dash-43) | basse | bug | S | La date de l'en-tête n'est jamais rafraîchie | `client/src/components/DateWidget.tsx:16` |
|
||||||
|
| [DASH-44](#dash-44) | basse | perf-serveur | S | Logs volumineux à chaque appel dans les chemins les plus sollicités | `server/routes.ts:3263` |
|
||||||
|
| [DASH-45](#dash-45) | basse | dette-code | S | Code mort : RecentTasksCard, ResponsiveDashboard, server/cache.ts, imports et variables inutilisés | `client/src/components/tasks/RecentTasksCard.tsx:25` |
|
||||||
|
| [DASH-46](#dash-46) | basse | dette-code | S | Configurations de priorités et de types d'information dupliquées, avec des correspondances contre-intuitives | `client/src/pages/Dashboard.tsx:139` |
|
||||||
|
|
||||||
|
### DASH-01
|
||||||
|
|
||||||
|
**Pour les non-admins, le magasin sélectionné n'est pas envoyé : tâches toujours vides, données d'un autre magasin** — bug, sévérité haute, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:285`
|
||||||
|
- **Constat :** Dashboard.tsx:285 'if (selectedStoreId && user?.role === 'admin') params.append("storeId", ...)'. Même condition l. 37, 62, 79-81. Côté serveur, routes.ts:3237-3249 : non-admin sans storeId -> 'returning empty result' (res.json([])). routes.ts:1410-1420 : manager/directeur sans storeId -> 'groupIds = [userGroupIds[0]]'. routes.ts:4170-4178 : stats -> 'groupIds = userGroupIds' (tous les magasins). Les pages Commandes et Livraisons envoient storeId pour tous les rôles (Orders.tsx:72-73 'CRITICAL FIX').
|
||||||
|
- **Impact :** Un manager ou un directeur voit toujours 'Aucune tâche en cours / Toutes les tâches sont terminées'. Les commandes et livraisons affichées sont celles de son 1er magasin, quel que soit le magasin choisi, et les statistiques sont agrégées sur tous ses magasins. L'alerte DLC, elle, suit le magasin sélectionné : la page mélange 3 périmètres différents.
|
||||||
|
- **Recommandation :** Envoyer storeId pour tous les rôles dès qu'un magasin est sélectionné, comme dans Orders.tsx et Tasks.tsx. Centraliser la construction des URL dans un hook (ex. useStoreScopedUrl('/api/orders')) utilisé par toutes les pages, pour éviter de nouvelles divergences.
|
||||||
|
|
||||||
|
### DASH-02
|
||||||
|
|
||||||
|
**La fenêtre d'alerte DLC se rouvre aussitôt qu'on la ferme (croix, Échap, clic à l'extérieur)** — bug, sévérité haute, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:273`
|
||||||
|
- **Constat :** Dashboard.tsx:276-278 'handleCloseDlcAlertModal = () => setShowDlcAlertModal(false)'. L'effet l. 244-273 dépend de showDlcAlertModal : 'if (shouldShowDlcModal && !showDlcAlertModal) { ... } else { setShowDlcAlertModal(true); }' tant qu'aucun snooze n'est présent dans localStorage. Seul 'Traiter plus tard (2h)' (DlcAlertModal.tsx:106-112) écrit ce snooze.
|
||||||
|
- **Impact :** Un employé ou un manager ne peut pas fermer la fenêtre normalement : elle revient instantanément. Il est bloqué jusqu'à trouver le bouton 'Traiter plus tard (2h)', ce qui est vécu comme un bug.
|
||||||
|
- **Recommandation :** À la fermeture, mémoriser un rejet pour la session (sessionStorage 'dlcAlertDismissed' ou snooze court). Déclencher l'ouverture automatique une seule fois par chargement (useRef 'alreadyShown'), sans dépendre de showDlcAlertModal.
|
||||||
|
|
||||||
|
### DASH-03
|
||||||
|
|
||||||
|
**En production, user vaut null au montage du Dashboard : requêtes envoyées avec le mauvais périmètre puis téléchargées deux fois** — bug, sévérité haute, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/hooks/useAuthUnified.ts:23`
|
||||||
|
- **Constat :** useAuthUnified.ts:23-24 'const [productionUser, setProductionUser] = useState<any>(null)' : chaque instance refait son fetch('/api/user') (l. 160). Au montage, Dashboard.tsx:79 construit ordersUrl sans storeId car user?.role est undefined, puis avec storeId une fois user chargé : la clé change et l'historique complet est retéléchargé. Pour /api/stats/yearly (l. 55) et /api/tasks (l. 282), la queryKey ne contient pas le rôle : le résultat 'tous magasins' obtenu avec user=null reste en cache sous la clé du magasin sélectionné.
|
||||||
|
- **Impact :** Pour un admin avec un magasin sélectionné, l'historique de tous les magasins (commandes, livraisons, commandes clients) est téléchargé puis jeté. Le délai moyen et la liste des tâches affichent les chiffres de tous les magasins au lieu du magasin choisi.
|
||||||
|
- **Recommandation :** Faire de useAuthUnified un simple useQuery(['/api/user'], { staleTime: Infinity }) partagé par le cache, ou un AuthContext fourni par RouterProduction. user est alors disponible dès le 1er rendu. Ajouter 'enabled: !!user' aux requêtes du Dashboard qui dépendent du rôle.
|
||||||
|
|
||||||
|
### DASH-05
|
||||||
|
|
||||||
|
**L'historique complet des commandes et livraisons est téléchargé pour afficher 4 compteurs et 2 courtes listes** — perf-api, sévérité haute, effort L
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:84`
|
||||||
|
- **Constat :** Dashboard.tsx:84-90 'useQuery({ queryKey: [ordersUrl, selectedStoreId] })' sur '/api/orders' et '/api/deliveries' sans filtre de date. Ils servent à calculer côté client deliveredThisMonth (l. 318), totalPalettes (l. 328), pendingOrders (l. 302) et upcomingDeliveries (.slice(0, 4), l. 313). Côté serveur, storage.getOrders (storage.ts:678-723) : pas de LIMIT, livraisons imbriquées via loadDeliveriesByOrderIds. storage.getDeliveries (885-926) : pas de LIMIT, commande imbriquée et nombre de commentaires. getTasks (storage.ts:2267) renvoie aussi toutes les tâches, terminées comprises, pour en afficher 5.
|
||||||
|
- **Impact :** Le temps de chargement et la mémoire grandissent avec l'historique (des Mo de JSON après quelques années), surtout sur réseau magasin ou 4G. Le serveur sérialise des milliers de lignes à chaque ouverture du tableau de bord.
|
||||||
|
- **Recommandation :** Créer GET /api/dashboard/summary?storeId=. Il renverrait, calculés en SQL (COUNT/SUM avec WHERE et LIMIT) et lancés en parallèle (Promise.all) : nombre de commandes en attente, livraisons et palettes reçues ce mois, délai moyen, les 10 commandes en attente les plus anciennes, les 4 prochaines livraisons planifiées, les 3 prochaines publicités, les 5 tâches ouvertes prioritaires, les compteurs DLC et la dernière information. Une seule requête légère au lieu de 11.
|
||||||
|
|
||||||
|
### DASH-07
|
||||||
|
|
||||||
|
**Chaque ligne embarque l'objet magasin complet, logo base64 et configuration NocoDB/SMTP compris** — perf-serveur, sévérité haute, effort M — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:693`
|
||||||
|
- **Constat :** storage.ts:692-693 'supplier: suppliers, group: groups,' dans getOrders. Même chose dans loadDeliveriesByOrderIds (l. 552-553), getDeliveries (l. 912-913), loadOrdersByIds, getCustomerOrders (l. 1868), getDlcProducts (l. 1980), getTasks (l. 2271) et getPublicities (l. 1536). Or shared/schema.ts:67 'logo: text("logo"), // Logo en data URI (data:image/png;base64,...)', l. 63 webhookUrl, l. 74 smtpPassword. Pour masquer smtpPassword, routes.ts:168 copie en profondeur chaque réponse : 'res.json = (body) => originalJson(stripSmtpPassword(body))'.
|
||||||
|
- **Impact :** Le logo base64 (souvent 10 à 100 Ko) est répété dans chaque commande, chaque livraison imbriquée et chaque produit DLC : la taille des réponses est multipliée et le parse JSON côté navigateur ralentit. Le serveur reparcourt et recopie tout le JSON à chaque réponse. La configuration interne (webhook, NocoDB, hôte et utilisateur SMTP) est exposée à tous les utilisateurs.
|
||||||
|
- **Recommandation :** Définir des projections par usage. Pour les listes (commandes, tâches, DLC, commandes clients, publicités) : { id, name, color }. Pour les livraisons et avoirs utilisés en rapprochement : ajouter webhookUrl, nocodbConfigId et nocodbTableName. Ne jamais sélectionner logo ni smtp*. Vérifier chaque appelant client par grep sur `.group?.` avant de changer une projection. Garder stripSmtpPassword global comme filet de sécurité. Son coût baissera de lui-même avec des objets plus petits.
|
||||||
|
|
||||||
|
### DASH-08
|
||||||
|
|
||||||
|
**Pas de compression HTTP ni de cache long sur les assets, et le middleware de compression maison est faux et inutilisé** — perf-serveur, sévérité haute, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/index.production.ts:143`
|
||||||
|
- **Constat :** server/index.production.ts:27-28 : express.json et urlencoded, aucun middleware compression. l. 143 'app.use('/assets', express.static(join(publicPath, 'assets')))' sans maxAge ni immutable. server/cache.ts:93-100 : setupCompression pose 'Content-Encoding: gzip' sans rien compresser, et n'est importé nulle part (grep vide). Aucun nginx/gzip dans le dépôt. Assets dist/public/assets : index 1,08 Mo + vendor-charts 410 Ko + vendor-react 146 Ko + CSS 121 Ko servis bruts.
|
||||||
|
- **Impact :** Environ 1,8 Mo de JS/CSS non compressé au premier chargement (environ 450 Ko en gzip). Les assets sont revalidés à chaque visite, et les grosses réponses JSON (commandes, livraisons) circulent 5 à 10 fois plus lourdes que nécessaire.
|
||||||
|
- **Recommandation :** (1) Mécanique : servir /assets avec express.static(..., { maxAge: '1y', immutable: true }) dans index.production.ts:143, sans toucher au static '/' ni à index.html. Supprimer setupCompression de server/cache.ts, voire tout le fichier (DASH-45). (2) Ajouter la dépendance compression et @types/compression, puis app.use(compression()) avant registerProductionRoutes dans index.production.ts et dans index.ts. Vérifier au préalable la configuration gzip du nginx externe (réseau nginx_default).
|
||||||
|
|
||||||
|
### DASH-22
|
||||||
|
|
||||||
|
**Aucun état de chargement ni d'erreur : l'écran affiche de fausses informations pendant le chargement** — ux-simplicite, sévérité haute, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:435`
|
||||||
|
- **Constat :** Dashboard.tsx ne lit jamais isLoading ni isError (grep vide). Pendant le chargement : l. 435 '{deliveredThisMonth}' = 0, l. 533 'Aucune commande en attente', l. 701-702 'Aucune tâche en cours / Toutes les tâches sont terminées'. mobile/DashboardPage.tsx:63 récupère isLoading sans s'en servir. Les queryFn qui échouent renvoient [] ou null en silence (DashboardPage.tsx:88, 106, 124).
|
||||||
|
- **Impact :** L'utilisateur peut croire que tout est traité alors que les données ne sont pas encore arrivées ou que le serveur est en erreur. C'est risqué pour des décisions opérationnelles (DLC, retards).
|
||||||
|
- **Recommandation :** Afficher un squelette (Skeleton shadcn) par carte tant que isLoading est vrai. En cas d'erreur, afficher un message clair dans la carte ('Impossible de charger les commandes' + bouton 'Réessayer'). Ne montrer les messages d'état vide qu'après un chargement réussi.
|
||||||
|
|
||||||
|
### DASH-28
|
||||||
|
|
||||||
|
**Mobile : 'Commandes' et 'Livraisons' affichent des totaux historiques (paramètre date ignoré), et un appel part vers une route inexistante** — bug, sévérité haute, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DashboardPage.tsx:101`
|
||||||
|
- **Constat :** DashboardPage.tsx:101 'params.append('date', format(new Date(), 'yyyy-MM-dd'))' puis 'fetch(`/api/orders?${params}`)'. Le serveur ne lit que startDate/endDate : routes.ts:1351 'const { startDate, endDate, storeId } = req.query;'. Même chose pour /api/deliveries (l. 119-121). DashboardPage.tsx:69 'fetch(`/api/stats/dashboard?...`)' : aucune route de ce nom dans server/routes.ts (404), et 'stats' n'est jamais utilisé.
|
||||||
|
- **Impact :** Sur téléphone, l'employé voit par exemple '1 254 Commandes' en pensant que c'est l'activité du jour. L'application télécharge tout l'historique juste pour compter les lignes (.length), ce qui est lent en 4G, et fait un appel 404 inutile.
|
||||||
|
- **Recommandation :** Supprimer l'appel /api/stats/dashboard. Utiliser 'startDate=endDate=aujourd'hui', ou mieux l'endpoint de synthèse (DASH-05) qui renvoie directement les compteurs. Libeller 'Commandes du jour' et 'Livraisons du jour'.
|
||||||
|
|
||||||
|
### DASH-32
|
||||||
|
|
||||||
|
**Aucun découpage du code : les 30+ pages desktop et mobile et recharts sont chargées dès l'accueil** — perf-bundle, sévérité haute, effort M — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/RouterProduction.tsx:5`
|
||||||
|
- **Constat :** RouterProduction.tsx:5-44 : imports statiques de toutes les pages (Analytics, BLReconciliation, Avoirs, DatabaseDebug… et les 11 pages mobiles). Aucun React.lazy dans client/src (grep vide). vite.config.ts:41-46 : manualChunks 'vendor-charts': ['recharts'], que index.html précharge (modulepreload). Build : dist/public/assets/index-*.js = 1 081 745 octets, vendor-charts = 410 243 octets.
|
||||||
|
- **Impact :** Environ 1,7 Mo de JS à télécharger, parser et exécuter avant d'afficher l'accueil, alors que le tableau de bord n'utilise ni graphiques ni la plupart des pages. Premier affichage lent sur les PC de caisse et sur mobile.
|
||||||
|
- **Recommandation :** Charger les pages secondaires avec React.lazy dans un <Suspense fallback={<PageSkeleton/>}>, en gardant Dashboard, AuthPage et la page mobile d'accueil en import direct. Ajouter un gestionnaire d'erreur de chargement de chunk (rechargement unique de la page). Retirer 'vendor-charts' de manualChunks, ou passer à une fonction manualChunks qui n'y place que node_modules/recharts et d3-*. Vérifier après le build que index.html ne précharge plus ce chunk.
|
||||||
|
|
||||||
|
### DASH-04
|
||||||
|
|
||||||
|
**/api/user appelé 5 fois au chargement du tableau de bord, et 3 fois de plus à chaque navigation mobile** — perf-api, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/hooks/useAuthUnified.ts:160`
|
||||||
|
- **Constat :** useAuthUnified.ts:160 'const response = await fetch('/api/user', { credentials: 'include', cache: 'no-cache', ...' dans un useEffect propre à chaque instance. Instances sur le desktop : RouterProduction, Layout, Dashboard, AnnouncementCard, plus useAuthSimple dans Sidebar.tsx:44. Sur mobile : RouterProduction, MobileApp, DashboardPage, MobileLayout, MobileBottomNav. Côté serveur, chaque appel coûte la session + getUserWithGroups dans deserializeUser (localAuth.production.ts:193-196) + de nouveau getUserWithGroups dans localAuth.ts:211.
|
||||||
|
- **Impact :** Environ 5 allers-retours et 20 à 25 requêtes SQL de plus à chaque ouverture. Sur mobile, les requêtes de la page attendent le /api/user de leur propre instance, ce qui ajoute une étape à la cascade et retarde l'affichage.
|
||||||
|
- **Recommandation :** Une seule source d'auth (useQuery partagé ou contexte), comme pour DASH-03. Supprimer useAuthSimple au profit du même hook.
|
||||||
|
|
||||||
|
### DASH-06
|
||||||
|
|
||||||
|
**Deux requêtes dont le résultat n'est jamais affiché : /api/stats/monthly et /api/customer-orders** — perf-api, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:28`
|
||||||
|
- **Constat :** Dashboard.tsx:28 'const { data: stats } = useQuery({ queryKey: ['/api/stats/monthly', ...' : 'stats' n'est utilisé nulle part dans le JSX. Dashboard.tsx:92 'const { data: customerOrders = [] } = useQuery(...)' : il ne sert qu'à customerOrderStats (l. 380-387), jamais rendu. recentOrders (l. 293-299) et ordersByStatus (l. 372-377) ne sont pas utilisés non plus.
|
||||||
|
- **Impact :** 2 requêtes HTTP de plus, dont l'historique complet des commandes clients (storage.ts:1868, sans LIMIT), plus 4 requêtes SQL séquentielles pour les stats mensuelles, pour rien.
|
||||||
|
- **Recommandation :** Supprimer ces deux useQuery et les variables mortes recentOrders, ordersByStatus et customerOrderStats.
|
||||||
|
|
||||||
|
### DASH-09
|
||||||
|
|
||||||
|
**Chaque route relit l'utilisateur et ses magasins (2 requêtes) déjà chargés par passport** — perf-serveur, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:1345`
|
||||||
|
- **Constat :** routes.ts:1345 'const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);', répété l. 1754, 2799, 2833, 3195, 3488, 4157, 4194, 4597 et 5775. Or localAuth.production.ts:193-196 'passport.deserializeUser(async (id) => { const user = await storage.getUserWithGroups(id); ...' : req.user est déjà un UserWithGroups. Et getUserWithGroups (storage.ts:371-405) fait 2 requêtes séquentielles (getUser puis user_groups JOIN groups).
|
||||||
|
- **Impact :** Par requête API : session + 2 + 2 requêtes SQL séquentielles avant la logique métier. Sur le tableau de bord (environ 20 appels), cela fait environ 80 requêtes SQL inutiles et autant de latence ajoutée.
|
||||||
|
- **Recommandation :** Introduire un helper `getCurrentUser(req): UserWithGroups` qui renvoie req.user. L'appliquer d'abord aux routes chaudes du tableau de bord (orders, deliveries, tasks, stats, dlc stats, ad-campaigns, announcements), puis progressivement. Réécrire getUserWithGroups en une requête (users LEFT JOIN user_groups LEFT JOIN groups) en conservant exactement le format { ...user, userGroups: [{ userId, groupId, group: { id, name, color, createdAt, updatedAt } }] }.
|
||||||
|
|
||||||
|
### DASH-10
|
||||||
|
|
||||||
|
**Statistiques mensuelles et annuelles : 4 requêtes SQL séquentielles, non mises en cache** — perf-serveur, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:1270`
|
||||||
|
- **Constat :** storage.ts:1270 'const ordersResult = await db...', l. 1276 'const deliveriesResult = await db...', l. 1287 'const pendingResult = await db...', l. 1308 'const deliveriesStatsResult = await db...' : 4 await successifs indépendants. Même schéma dans getYearlyStats (l. 1409, 1415, 1426, 1446), dont le Dashboard n'utilise que averageDeliveryTime (Dashboard.tsx:317).
|
||||||
|
- **Impact :** La latence de l'endpoint vaut la somme de 4 allers-retours BD au lieu du plus long. Les statistiques annuelles, qui bougent peu, sont recalculées à chaque ouverture.
|
||||||
|
- **Recommandation :** Auto : seulement Promise.all sur les 4 requêtes de getMonthlyStats et de getYearlyStats. Le cache mémoire est à décider séparément, avec invalidation sur création, modification ou validation de commande et de livraison.
|
||||||
|
|
||||||
|
### DASH-11
|
||||||
|
|
||||||
|
**Publicités à venir : 3 appels séquentiels qui téléchargent 3 années complètes pour en afficher 3** — perf-api, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:205`
|
||||||
|
- **Constat :** Dashboard.tsx:202-219 'const years = [currentYear - 1, currentYear, currentYear + 1]; for (const year of years) { ... const response = await fetch(`/api/ad-campaigns?${params}`) ...' puis un filtre et un tri côté client (l. 222-224), et '.slice(0, 3)' au rendu (l. 584). Côté serveur, getPublicities (storage.ts:1502-1560) ignore groupIds et associe les participations par 'participations.filter((p) => p.publicityId === publicity.id)' (l. 1552-1553), soit O(n×m).
|
||||||
|
- **Impact :** 3 allers-retours en cascade (l'année N-1 ne peut pas contenir de publicité future), et des centaines de publicités avec leurs participations (et le magasin complet, cf. DASH-07) transférées pour 3 lignes.
|
||||||
|
- **Recommandation :** Ajouter GET /api/ad-campaigns/upcoming?limit=3 : WHERE start_date > CURRENT_DATE ORDER BY start_date LIMIT 3 (l'index idx_publicities_start_date existe déjà, migrations/0000_flashy_blur.sql:260). En attendant, faire les appels N et N+1 en Promise.all et supprimer N-1. Côté storage, grouper les participations dans une Map.
|
||||||
|
|
||||||
|
### DASH-12
|
||||||
|
|
||||||
|
**Informations : requête N+1 (auteur et magasin chargés un par un) et contenu complet écrit dans les logs** — perf-serveur, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:5815`
|
||||||
|
- **Constat :** routes.ts:5815 'messages.map(async (message) => { ... const [userResult] = await db.select({...}).from(users).where(eq(users.username, message.createdBy));' (l. 5821) puis 'const [groupResult] = await db.select().from(groups).where(eq(groups.id, message.storeId));' (l. 5852). l. 5811 : 'console.log('🔍 [PRODUCTION] Raw messages from DB:', messages.length, 'items:', messages);'.
|
||||||
|
- **Impact :** Jusqu'à 10 requêtes SQL de plus par appel, et cet appel est fait 2 fois par ouverture (DASH-14). Le select().from(groups) ramène aussi le logo base64. Les logs contiennent le texte intégral des annonces.
|
||||||
|
- **Recommandation :** Une requête dashboardMessages LEFT JOIN users ON users.username = created_by LEFT JOIN groups ON groups.id = store_id. Sélectionner users.id, username, firstName, lastName, name et groups.id, name. Recalculer `author` (même logique de displayName et même repli) et `group` ({ id, name } ou null), pour garder exactement le format de réponse actuel. Supprimer les console.log l.5773, 5779, 5811, 5820, 5827 et 5865.
|
||||||
|
|
||||||
|
### DASH-13
|
||||||
|
|
||||||
|
**Les non-admins voient les informations de tous les magasins, et le paramètre 'recent' est ignoré** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:5797`
|
||||||
|
- **Constat :** routes.ts:5797 'if (user.role === 'admin' && req.query.storeId) { query = query.where(or(eq(dashboardMessages.storeId, storeId), isNull(dashboardMessages.storeId))) }' : aucun filtre pour les autres rôles. Le Dashboard envoie 'recent=true' (Dashboard.tsx:104), que le serveur ne lit jamais, et refiltre sur 2 jours côté client (l. 117-124).
|
||||||
|
- **Impact :** Un employé du magasin A voit les informations destinées au magasin B, ce qui crée de la confusion sur les consignes à appliquer. Avec la limite de 5, des informations globales peuvent aussi être évincées par celles d'autres magasins.
|
||||||
|
- **Recommandation :** Pour les non-admins, filtrer 'store_id IS NULL OR store_id IN (userGroupIds)', ou sur le magasin sélectionné s'il est autorisé. Gérer 'recent' côté SQL (created_at >= now() - interval '2 days') ou retirer ce paramètre.
|
||||||
|
|
||||||
|
### DASH-15
|
||||||
|
|
||||||
|
**Changer de magasin relance d'abord les requêtes de l'ancien magasin, puis affiche des zéros** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/Layout.tsx:138`
|
||||||
|
- **Constat :** Layout.tsx:138-146 'queryClient.invalidateQueries({ predicate: ... includes('/api/orders') || ... '/api/deliveries' || '/api/stats/monthly' || '/api/tasks' })' est appelé avant 'setSelectedStoreId(newStoreId)' (l. 154). Les requêtes encore actives, qui portent la clé de l'ancien magasin, sont donc refetchées. Toutes ces clés contiennent déjà selectedStoreId, et aucune requête du Dashboard n'a de placeholderData.
|
||||||
|
- **Impact :** À chaque changement de magasin, l'historique complet des commandes et livraisons de l'ancien magasin est retéléchargé pour rien. Ensuite, l'écran affiche '0' et 'Aucune...' le temps du nouveau chargement.
|
||||||
|
- **Recommandation :** Auto : supprimer seulement l'appel invalidateQueries de Layout.tsx:138-146. L'ajout de placeholderData: keepPreviousData, avec un indicateur 'Mise à jour…', est à traiter séparément comme décision produit.
|
||||||
|
|
||||||
|
### DASH-17
|
||||||
|
|
||||||
|
**Calculs lourds refaits à chaque rendu, sans useMemo, et tri qui modifie le cache React Query** — perf-client, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:293`
|
||||||
|
- **Constat :** Dashboard.tsx:293-294 'const recentOrders = Array.isArray(allOrders) ? allOrders.sort(...)' : .sort() trie sur place le tableau stocké dans le cache, partagé avec la page Commandes pour l'admin. Puis 4 passes .filter sur allOrders (l. 302, 316, 373-375), 2 sur allDeliveries (l. 318, 328) et 5 sur customerOrders (l. 381-385), avec safeDate() à chaque comparaison. Le composant est re-rendu à chaque fin d'une des environ 12 requêtes.
|
||||||
|
- **Impact :** Sur un historique de plusieurs milliers de lignes, des dizaines de millisecondes de calcul par rendu, répétées une douzaine de fois au chargement : interface saccadée sur les PC de magasin modestes. La mutation du cache peut aussi changer l'ordre d'une autre page.
|
||||||
|
- **Recommandation :** Regrouper les dérivations dans un useMemo([allOrders, allDeliveries]) en une seule passe, et copier avant de trier ('[...allOrders].sort'). Mieux : déplacer ces agrégats côté serveur (DASH-05).
|
||||||
|
|
||||||
|
### DASH-18
|
||||||
|
|
||||||
|
**Le badge 'jours en attente' affiche des valeurs négatives et n'a pas de légende** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:506`
|
||||||
|
- **Constat :** Dashboard.tsx:505-507 'const orderDate = safeDate(order.plannedDate); const daysPending = Math.floor((new Date().getTime() - orderDate.getTime()) / (1000*60*60*24)); const isOverdue = daysPending > 10;' puis l. 524 '{daysPending} jour{daysPending > 1 ? 's' : ''}'. Une commande 'pending' a généralement une date prévue future.
|
||||||
|
- **Impact :** L'utilisateur lit '-4 jour' ou '3 jours' sans savoir s'il s'agit d'un retard ou d'un délai restant. Le rouge n'apparaît qu'au-delà de 10 jours, de façon arbitraire, donc une commande en retard d'un jour reste orange.
|
||||||
|
- **Recommandation :** Si la date prévue est passée, afficher 'En retard de X j' en rouge. Sinon, afficher 'Prévue le 12 oct.' en neutre. Trier les retards en premier et limiter la liste à 5-10 lignes avec un lien 'Voir toutes les commandes en attente'.
|
||||||
|
|
||||||
|
### DASH-19
|
||||||
|
|
||||||
|
**Badges de priorité des tâches : le variant est passé dans className, donc toutes les priorités ont la même couleur** — coherence-design, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:687`
|
||||||
|
- **Constat :** Dashboard.tsx:686-688 '<Badge className={`text-xs flex items-center gap-1 ${priorityConfig.color}`}>' alors que getPriorityConfig (l. 341-368) renvoie color: 'destructive' | 'default' | 'secondary', qui sont des noms de variant et non des classes CSS. RecentTasksCard.tsx:152 fait correctement 'variant={priorityConfig.color}'.
|
||||||
|
- **Impact :** 'Élevée', 'Moyenne' et 'Faible' s'affichent tous en bleu primaire : l'urgence ne se voit pas d'un coup d'œil.
|
||||||
|
- **Recommandation :** Remplacer par `<Badge variant={priorityConfig.color} className="text-xs flex items-center gap-1">`, puis partager la configuration via DASH-46 (et non DASH-34).
|
||||||
|
|
||||||
|
### DASH-21
|
||||||
|
|
||||||
|
**Texte de debug technique visible par les utilisateurs** — lisibilite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:642`
|
||||||
|
- **Constat :** Dashboard.tsx:642 '<p className="text-xs text-gray-400 mt-1">(API: {Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY'})</p>'. l. 568 '({Array.isArray(allDeliveries) ? allDeliveries.length : 0} livraisons totales)'. Aussi un console.log dans le queryFn (l. 217).
|
||||||
|
- **Impact :** Des mentions comme '(API: 0)' ou '(API: NOT_ARRAY)' font croire à une panne et nuisent à la confiance dans l'outil.
|
||||||
|
- **Recommandation :** Auto : supprimer seulement les deux <p> de debug (l.568 et l.642) en gardant les messages 'Aucune livraison programmée' et 'Aucune publicité à venir'. L'ajout d'un lien d'action est à traiter avec DASH-23.
|
||||||
|
|
||||||
|
### DASH-23
|
||||||
|
|
||||||
|
**Rien n'est cliquable alors que tout réagit au survol** — ux-simplicite, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:430`
|
||||||
|
- **Constat :** Dashboard.tsx:430 '<Card className="... shadow-lg hover:shadow-xl transition-shadow">' sur les 4 compteurs et les 5 cartes. Les lignes ont 'hover:bg-orange-100' (l. 510), mais il n'y a aucun Link ni onClick (grep 'Link|navigate|href' vide). Les bandeaux DLC (l. 409-425) ne mènent pas non plus à /dlc.
|
||||||
|
- **Impact :** L'utilisateur voit '12 commandes en attente' mais doit passer par le menu, puis chercher et filtrer lui-même. L'effet de survol laisse croire à un clic qui ne fait rien.
|
||||||
|
- **Recommandation :** Rendre chaque compteur et chaque ligne cliquables : /orders?status=pending, /deliveries?status=planned, /dlc?status=expires, /tasks, /publicities. Ajouter un lien 'Tout voir' en pied de carte. Retirer l'effet hover-shadow sur ce qui n'est pas cliquable.
|
||||||
|
|
||||||
|
### DASH-24
|
||||||
|
|
||||||
|
**Alertes DLC affichées en double (bandeaux + fenêtre), et deux fenêtres automatiques peuvent s'empiler** — ux-simplicite, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:409`
|
||||||
|
- **Constat :** Dashboard.tsx:409-425 : bandeaux jaune et rouge DLC. l. 244-273 : ouverture automatique de DlcAlertModal avec les mêmes chiffres. l. 174-182 : ouverture automatique de la fenêtre 'Nouvelle Annonce'. Les deux Dialog (l. 710 et 773) sont indépendants : rien n'empêche open=true sur les deux à la fois pour un non-admin.
|
||||||
|
- **Impact :** À la connexion, un employé peut recevoir deux fenêtres superposées en plus de deux bandeaux : surcharge et confusion sur ce qu'il faut faire en premier.
|
||||||
|
- **Recommandation :** Créer un seul bloc 'À traiter aujourd'hui' en haut de page (DLC expirés, DLC proches, commandes en retard), avec un bouton d'action chacun. Afficher au plus une fenêtre automatique par session, avec une file : l'information d'abord, l'alerte DLC ensuite.
|
||||||
|
|
||||||
|
### DASH-25
|
||||||
|
|
||||||
|
**Libellés ambigus, majuscules à l'anglaise, et numéro de publicité affiché à la place de la désignation** — lisibilite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:476`
|
||||||
|
- **Constat :** l. 476 'Total palettes' : en réalité les palettes livrées ce mois (l. 328-338). l. 462 'Délai commande → livraison' : en réalité la moyenne annuelle (yearlyStats). l. 595-596 : '{publicity.pubNumber}' en gras et '{publicity.designation}' tronquée à max-w-40 (160 px). Titres 'Tableau de Bord', 'Commandes en Attente', 'Livraisons à Venir', 'Publicités à Venir' à côté de 'Tâches à faire'. Sous-titre générique l. 400 'Vue d'ensemble des performances et statistiques'.
|
||||||
|
- **Impact :** Un employé ne sait pas sur quelle période portent les chiffres, et ne reconnaît pas une opération publicitaire à son numéro interne.
|
||||||
|
- **Recommandation :** Renommer en 'Palettes reçues ce mois', 'Délai moyen commande → livraison (2026)', 'Livraisons ce mois'. Pour les publicités, afficher la désignation en premier, puis 'Pub n° 2541 · du 12 au 18 oct.'. Écrire les titres en casse française ('Commandes en attente').
|
||||||
|
|
||||||
|
### DASH-26
|
||||||
|
|
||||||
|
**Statistiques 'Palettes' et 'Colis' fausses : unités mélangées et nombre de livraisons présenté comme nombre de colis** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:1310`
|
||||||
|
- **Constat :** storage.ts:1310-1311 'totalPalettes: sql`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`, totalPackages: sql`COALESCE(COUNT(*), 0)`' sans condition sur deliveries.unit (même chose l. 1448-1449). Affiché dans StatsPanel.tsx:79-87 comme 'Palettes' et 'Colis'. StatsPanel.tsx:101-104 affiche aussi pendingOrdersCount, qui couvre tout l'historique, sous le titre 'Statistiques du mois'.
|
||||||
|
- **Impact :** Les volumes mensuels sont faux (colis comptés comme palettes, 'Colis' = nombre de livraisons) et peuvent fausser le pilotage du magasin.
|
||||||
|
- **Recommandation :** Utiliser SUM(CASE WHEN unit='palettes' THEN quantity ELSE 0 END) et SUM(CASE WHEN unit='colis' THEN quantity ELSE 0 END), calculés sans innerJoin orders : seul le délai moyen a besoin de la jointure. Libeller pendingOrdersCount 'Commandes en attente (toutes dates)'.
|
||||||
|
|
||||||
|
### DASH-27
|
||||||
|
|
||||||
|
**Compteurs DLC incohérents avec la liste de la fenêtre : produits du jour comptés deux fois, 'stock épuisé' traités différemment** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:2245`
|
||||||
|
- **Constat :** storage.ts:2245 'expiryDate BETWEEN today AND alertDate' (expiringSoon) et l. 2251 'expiryDate <= today' (expired) : un produit qui expire aujourd'hui est compté dans les deux. Les stats excluent stockEpuise=true, mais la liste 'expires' (l. 2019-2028) ne filtre pas stockEpuise, et DlcAlertModal.tsx:196-199 affiche alors un badge 'Stock épuisé'. La date est calculée en UTC : 'today.toISOString().split('T')[0]'.
|
||||||
|
- **Impact :** Le titre 'Produits Expirés (3)' ne correspond pas au nombre de lignes affichées. Les bandeaux additionnent deux fois les produits du jour. Entre minuit et 2 h, heure de Paris, c'est la date de la veille qui est utilisée.
|
||||||
|
- **Recommandation :** Définir une seule fois les conditions SQL (expiré : expiry_date < aujourd'hui ; bientôt : entre aujourd'hui et aujourd'hui + 15 ; mêmes exclusions status != 'valides', processed_until_expiry et stock_epuise) et les réutiliser pour les stats et les listes. Pour 'aujourd'hui', utiliser `(now() AT TIME ZONE 'Europe/Paris')::date` plutôt que CURRENT_DATE nu ou toISOString.
|
||||||
|
|
||||||
|
### DASH-29
|
||||||
|
|
||||||
|
**Chaque page mobile remonte l'en-tête et la barre du bas, ce qui relance /api/user à chaque navigation** — perf-client, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DashboardPage.tsx:136`
|
||||||
|
- **Constat :** DashboardPage.tsx:136 '<MobileLayout>' est rendu dans la page : 10 pages mobiles font de même (grep '<MobileLayout'). MobileLayout.tsx:22 et MobileBottomNav.tsx:47 appellent chacun useAuthUnified(), qui fait un fetch('/api/user') au montage (useAuthUnified.ts:160).
|
||||||
|
- **Impact :** À chaque tap dans la barre du bas, l'en-tête et la navigation sont démontés puis remontés (perte d'état, petit clignotement), avec 3 appels /api/user. Le contenu attend la réponse avant de charger (enabled: !!user).
|
||||||
|
- **Recommandation :** Déplacer MobileLayout dans MobileApp, autour du <Switch> de RouterProduction.tsx:108-143. Passer le titre de page par contexte ou par prop de route. Utiliser une auth partagée (DASH-04).
|
||||||
|
|
||||||
|
### DASH-30
|
||||||
|
|
||||||
|
**Les liens 'Accès rapide' et le bouton de la fenêtre DLC rechargent toute l'application** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DashboardPage.tsx:187`
|
||||||
|
- **Constat :** DashboardPage.tsx:187-191 '<a key={item.path} href={item.path} ...>' (lien HTML natif, pas le <Link> de wouter). DlcAlertModal.tsx:141-143 'const handleViewDlcModule = () => { window.location.href = '/dlc'; };'.
|
||||||
|
- **Impact :** Chaque clic recharge le JS (environ 1,7 Mo non compressé, cf. DASH-08), refait l'authentification et vide le cache React Query. Sur mobile, cela représente plusieurs secondes d'attente pour une simple navigation interne.
|
||||||
|
- **Recommandation :** Remplacer par '<Link href={item.path}>' (wouter) dans DashboardPage, et par 'const [, navigate] = useLocation(); navigate('/dlc'); onClose();' dans DlcAlertModal.
|
||||||
|
|
||||||
|
### DASH-31
|
||||||
|
|
||||||
|
**Mobile : les accès rapides doublonnent la barre du bas, avec des libellés trompeurs, et les alertes utiles manquent** — ux-simplicite, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DashboardPage.tsx:179`
|
||||||
|
- **Constat :** DashboardPage.tsx:179-184 '{ label: "Commande", path: "/orders" }, { label: "Livraison", ... }, { label: "Tâche", ... }, { label: "Agenda", path: "/calendar" }'. MobileBottomNav.tsx:30-34 contient déjà Commandes, Livraisons et Tâches, et l. 37 nomme '/calendar' 'Calendrier'. La page mobile n'a ni alerte DLC ni informations, contrairement au desktop (Dashboard.tsx:409-425, 652).
|
||||||
|
- **Impact :** La place précieuse sur téléphone répète la navigation. 'Commande' au singulier laisse croire à un bouton 'Créer', le même écran porte deux noms, et l'employé en rayon ne voit pas les produits DLC à retirer.
|
||||||
|
- **Recommandation :** Remplacer les accès rapides par un bloc 'À traiter' : DLC expirés et proches (lien /dlc), information du jour, commandes clients à appeler. Ou proposer de vraies actions de création ('+ Nouvelle livraison'). Utiliser le même nom partout ('Calendrier').
|
||||||
|
|
||||||
|
### DASH-35
|
||||||
|
|
||||||
|
**Météo : appels séquentiels sans timeout, et l'API externe est rappelée à chaque requête si l'historique échoue** — perf-serveur, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:5683`
|
||||||
|
- **Constat :** routes.ts:5673 'const settings = await storage.getWeatherSettings();', l. 5683 'let currentYearData = await storage.getWeatherData(today, true);', l. 5684 'let previousYearData = await ...' : séquentiel. Puis fetchCurrentWeather et fetchPreviousYearWeather, aussi séquentiels (l. 5687-5720). weatherService.ts:27 et 51 : 'await fetch(url)' sans AbortSignal ni timeout. En cas d'échec de l'historique (quota, 401), rien n'est enregistré : chaque appel suivant rappelle Visual Crossing.
|
||||||
|
- **Impact :** Si l'API météo est lente, la requête bloque une des 6 connexions HTTP/1.1 du navigateur pendant tout le chargement. Le quota API s'épuise vite, ce qui entretient les échecs.
|
||||||
|
- **Recommandation :** Auto : paralléliser les deux lectures BD, puis les deux fetch externes (Promise.all), et ajouter `signal: AbortSignal.timeout(3000)` aux deux fetch de weatherService.ts. Le cache mémoire de 30 min et le cache négatif de la journée sont à valider séparément : ils empêchent de réessayer après une panne transitoire.
|
||||||
|
|
||||||
|
### DASH-37
|
||||||
|
|
||||||
|
**Informations : suppression sans confirmation avec une icône 'X', boutons de 24 px sans libellé, texte non tronqué** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/AnnouncementCard.tsx:356`
|
||||||
|
- **Constat :** AnnouncementCard.tsx:352-360 '<Button variant="ghost" size="sm" className="h-6 w-6 p-0 ..." onClick={() => deleteMutation.mutate(announcement.id)}><X className="h-3 w-3" /></Button>' : pas d'AlertDialog, pas d'aria-label. l. 324 '<p className="... whitespace-pre-wrap break-words">{announcement.content}</p>' : contenu entier. Vocabulaire mélangé : titre 'Informations' (l. 275), bouton 'Nouvelle' (l. 294), fenêtre du Dashboard 'Nouvelle Annonce' (Dashboard.tsx:715), état vide 'Créez la première annonce' (l. 308).
|
||||||
|
- **Impact :** Un admin qui veut 'fermer' une information la supprime définitivement en un clic. Les cibles de 24 px sont difficiles à viser. Une information longue étire la carte et pousse les tâches hors de l'écran.
|
||||||
|
- **Recommandation :** Utiliser l'icône Trash2 avec un AlertDialog 'Supprimer cette information ?', des boutons d'au moins 36 px avec aria-label 'Modifier' et 'Supprimer', et line-clamp-3 avec 'Lire la suite'. Choisir un seul terme ('Information') partout.
|
||||||
|
|
||||||
|
### DASH-38
|
||||||
|
|
||||||
|
**Mêmes queryKeys pour des URL différentes (et l'inverse) : les données affichées dépendent de la page visitée en premier** — bug, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:282`
|
||||||
|
- **Constat :** ['/api/tasks', selectedStoreId] : Dashboard.tsx:282-285 n'envoie storeId que pour l'admin, Tasks.tsx:327-333 et mobile/DashboardPage.tsx:80-83 l'envoient pour tous. ['/api/dlc-products/stats', selectedStoreId] : DlcPage.tsx:96-99 n'envoie storeId que pour l'admin, Dashboard.tsx:236 pour tous. Inversement, '/api/stats/monthly' est rangé sous ['/api/stats/monthly', id] (Dashboard.tsx:29) et sous [statsUrl, id] (StatsPanel.tsx:21).
|
||||||
|
- **Impact :** Après un passage par la page Tâches, l'accueil d'un manager affiche ses tâches pendant 30 s, puis la liste se vide au refetch. Les compteurs DLC peuvent différer entre l'accueil et la page DLC selon l'ordre de visite. La même statistique est téléchargée deux fois.
|
||||||
|
- **Recommandation :** Centraliser les requêtes dans un module (ex. client/src/lib/queries.ts : tasksQuery(storeId), dlcStatsQuery(storeId)…). La queryKey doit être exactement l'URL appelée, construite par une seule fonction partagée par toutes les pages.
|
||||||
|
|
||||||
|
### DASH-14
|
||||||
|
|
||||||
|
**Les informations sont récupérées deux fois sous deux clés différentes, qui ne s'invalident pas ensemble** — perf-api, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:97`
|
||||||
|
- **Constat :** Dashboard.tsx:98 'queryKey: ['/api/announcements/recent', selectedStoreId]' appelle '/api/announcements?...&recent=true'. AnnouncementCard.tsx:134 'queryKey: ['/api/announcements', selectedStoreId, user?.role]' appelle la même route. Après création ou suppression, AnnouncementCard.tsx:192 et 226 n'invalident que ['/api/announcements'], préfixe qui ne couvre pas '/api/announcements/recent'.
|
||||||
|
- **Impact :** Une requête en double (avec son N+1 serveur) à chaque ouverture. Une information supprimée peut encore surgir dans la fenêtre 'Nouvelle Annonce' tant que le cache n'est pas périmé.
|
||||||
|
- **Recommandation :** Dans Dashboard, utiliser exactement la clé ['/api/announcements', selectedStoreId, user?.role] avec le même queryFn que AnnouncementCard, extrait dans une fonction partagée (même URL, renvoie le tableau). Calculer l'annonce récente (moins de 2 jours, la plus récente) via l'option `select` ou un useMemo. Les invalidations existantes couvriront alors les deux usages.
|
||||||
|
|
||||||
|
### DASH-16
|
||||||
|
|
||||||
|
**Les requêtes DLC sont invalidées à chaque montage du tableau de bord, ce qui annule le cache** — perf-client, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:23`
|
||||||
|
- **Constat :** Dashboard.tsx:23-26 'useEffect(() => { queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] }); queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] }); }, [selectedStoreId, queryClient]);'. Même effet dans DlcAlertModal.tsx:45-47. Les clés contiennent déjà selectedStoreId (l. 232).
|
||||||
|
- **Impact :** Chaque retour sur l'accueil relance les stats DLC (et marque comme périmées les listes DLC de la page DLC), même si elles datent de quelques secondes.
|
||||||
|
- **Recommandation :** Corriger d'abord DASH-38 : une clé égale à l'URL réellement appelée, construite par une fonction partagée entre Dashboard et DlcPage. Supprimer ensuite les deux useEffect d'invalidation.
|
||||||
|
|
||||||
|
### DASH-20
|
||||||
|
|
||||||
|
**La classe 'border-l-3' n'existe pas dans Tailwind 3 : la barre colorée à gauche des lignes est invisible** — coherence-design, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:510`
|
||||||
|
- **Constat :** Dashboard.tsx:510, 548, 590, 676 et AnnouncementCard.tsx:316 utilisent 'border-l-3 border-orange-500' (etc.). tailwind.config.ts n'étend pas borderWidth et index.css ne définit pas .border-l-3. Tailwind 3.4 ne fournit que border-l, -0, -2, -4 et -8.
|
||||||
|
- **Impact :** Le code couleur prévu (orange en attente, rouge en retard, vert livraisons, violet publicités) n'apparaît pas : les lignes se ressemblent toutes et la lecture rapide en souffre.
|
||||||
|
- **Recommandation :** Remplacer par 'border-l-4' ou 'border-l-[3px]'. Pour les informations, adapter la couleur au type (rouge Important, orange Attention…).
|
||||||
|
|
||||||
|
### DASH-33
|
||||||
|
|
||||||
|
**Script de bannière Replit (domaine tiers) chargé en production** — perf-client, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/index.html:19`
|
||||||
|
- **Constat :** client/index.html:18-19 '<!-- This is a replit script ... development mode --> <script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>', présent aussi dans le build dist/public/index.html.
|
||||||
|
- **Impact :** Une requête DNS + TLS vers un domaine tiers à chaque chargement, avec un script bloquant en fin de body. Exécution de code externe inutile sur un outil interne.
|
||||||
|
- **Recommandation :** Supprimer cette balise, ou l'injecter seulement en dev via un plugin Vite conditionnel.
|
||||||
|
|
||||||
|
### DASH-34
|
||||||
|
|
||||||
|
**Page déclarée en anglais et zoom bloqué sur mobile** — accessibilite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/index.html:5`
|
||||||
|
- **Constat :** client/index.html:2 '<html lang="en">' alors que toute l'interface est en français. l. 5 '<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />'.
|
||||||
|
- **Impact :** Chrome propose de 'traduire la page', les lecteurs d'écran prononcent le français avec une voix anglaise, et les utilisateurs malvoyants ne peuvent pas zoomer sur téléphone (WCAG 1.4.4).
|
||||||
|
- **Recommandation :** Auto : passer seulement à <html lang="fr">. Retirer maximum-scale=1 après avoir vérifié (grep) que les champs (input, select, textarea) affichés sous 768 px ont tous une police d'au moins 16 px.
|
||||||
|
|
||||||
|
### DASH-36
|
||||||
|
|
||||||
|
**Météo non configurée : retry inutile puis disparition du bloc, avec décalage de l'en-tête** — ux-simplicite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/WeatherWidget.tsx:121`
|
||||||
|
- **Constat :** WeatherWidget.tsx:121-125 'useQuery({ queryKey: ['/api/weather/current'], refetchInterval: 30*60*1000, retry: 1 })'. routes.ts:5675-5677 renvoie 404 'Weather service not configured'. Pendant l'essai et le nouvel essai, l. 137 affiche 'Chargement météo...', puis l. 144-146 'return null'. WeatherWidget.tsx:27-54 retraduit aussi en français des conditions déjà traduites par le serveur (lang=fr, weatherService.translateConditionToFrench).
|
||||||
|
- **Impact :** Sur chaque ouverture de l'application sans météo configurée, un texte de chargement apparaît environ 1 s puis disparaît, ce qui fait bouger le sélecteur de magasin. Une requête en double est envoyée.
|
||||||
|
- **Recommandation :** Côté serveur, répondre 200 { configured: false }. Côté client, mettre 'retry: false' et 'staleTime: 30 min', et réserver une largeur fixe ou un squelette discret. Supprimer translateToFrench côté client.
|
||||||
|
|
||||||
|
### DASH-39
|
||||||
|
|
||||||
|
**Fenêtre DLC : formulations techniques ou alarmistes, et '0 jour(s)'** — lisibilite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/DlcAlertModal.tsx:178`
|
||||||
|
- **Constat :** DlcAlertModal.tsx:151 'Alerte Produits DLC - Action Requise'. l. 178 'Expiré depuis {Math.abs(getDaysUntilExpiry(product.expiryDate))} jour(s)' donne 'Expiré depuis 0 jour(s)' le jour même. l. 244 '{daysLeft <= 3 ? 'URGENT' : `${daysLeft}j`}'. Toasts avec emojis (l. 80 '✅ Produit marqué', l. 89 '❌ Erreur'). l. 285 '💡 Conseil : Ce modal réapparaîtra…'. Le terme 'modal' est un anglicisme technique.
|
||||||
|
- **Impact :** Les messages paraissent techniques ou anxiogènes. 'Expiré depuis 0 jour(s)' est déroutant et '5j' est peu lisible.
|
||||||
|
- **Recommandation :** Écrire 'Expire aujourd'hui' ou 'Expiré depuis 2 jours' (fonction de pluriel), 'Dans 5 jours', et 'Urgent' plutôt que 'URGENT'. Titre : 'Produits à retirer ou à surveiller'. Remplacer 'Ce modal' par 'Cette fenêtre'. Retirer les emojis des toasts pour rester cohérent avec les autres pages.
|
||||||
|
|
||||||
|
### DASH-40
|
||||||
|
|
||||||
|
**'Livraisons à venir' inclut les livraisons en retard, et les tâches sont triées différemment sur chaque écran** — ux-simplicite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:310`
|
||||||
|
- **Constat :** Dashboard.tsx:310-313 '.filter((d) => d.status === 'planned').sort(... scheduledDate ...).slice(0, 4)' : une livraison planifiée à une date passée s'affiche en tête sans signalement. Tâches : Dashboard.tsx:665-669 trie par createdAt croissant (les plus anciennes d'abord). mobile/DashboardPage.tsx:205-207 prend les 3 premières de l'ordre serveur (createdAt décroissant). RecentTasksCard.tsx:59-73 trie par priorité puis échéance.
|
||||||
|
- **Impact :** Une livraison en retard est présentée comme 'à venir'. Les tâches les plus urgentes ou en retard peuvent ne pas apparaître dans le top 5, et l'ordre change d'un écran à l'autre.
|
||||||
|
- **Recommandation :** Ajouter un badge rouge 'En retard' aux livraisons planifiées dans le passé, ou une section dédiée. Trier les tâches partout de la même façon : en retard, puis priorité haute, puis échéance la plus proche.
|
||||||
|
|
||||||
|
### DASH-41
|
||||||
|
|
||||||
|
**Textes secondaires en gris clair 'text-gray-400' de petite taille, sous le seuil de contraste** — accessibilite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:568`
|
||||||
|
- **Constat :** Dashboard.tsx:568 et 642 '<p className="text-xs text-gray-400 mt-1">', l. 702 'text-xs text-gray-400', AnnouncementCard.tsx:307 'text-xs text-gray-400'. #9ca3af sur fond blanc donne un ratio d'environ 2,5:1 (WCAG AA exige 4,5:1).
|
||||||
|
- **Impact :** Peu lisible sur les écrans de magasin en pleine lumière et pour les utilisateurs âgés.
|
||||||
|
- **Recommandation :** Utiliser au minimum text-gray-600 pour tout texte informatif, et réserver gray-400 aux éléments décoratifs.
|
||||||
|
|
||||||
|
### DASH-42
|
||||||
|
|
||||||
|
**Panneau de statistiques fixe et non repliable qui masque le calendrier** — ux-simplicite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/StatsPanel.tsx:54`
|
||||||
|
- **Constat :** StatsPanel.tsx:54 '<Card className="fixed bottom-6 right-6 min-w-80 shadow-lg border-gray-200">'. Même chose pour le squelette (l. 37). Aucun bouton pour replier ou fermer.
|
||||||
|
- **Impact :** Sur tablette ou petit écran, le panneau de 320 px recouvre les derniers jours du mois et leurs livraisons.
|
||||||
|
- **Recommandation :** L'intégrer dans le flux de la page (bandeau de 4 compteurs au-dessus du calendrier), ou le rendre repliable avec mémorisation de l'état. Ajouter 'placeholderData: keepPreviousData' pour éviter le squelette à chaque changement de mois.
|
||||||
|
|
||||||
|
### DASH-43
|
||||||
|
|
||||||
|
**La date de l'en-tête n'est jamais rafraîchie** — bug, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/DateWidget.tsx:16`
|
||||||
|
- **Constat :** DateWidget.tsx:16 'const now = new Date();' est calculé seulement au rendu, sans timer. Le Layout reste monté d'une page à l'autre.
|
||||||
|
- **Impact :** Sur un poste laissé ouvert la nuit (poste de réception, caisse), l'en-tête affiche encore la date de la veille le matin.
|
||||||
|
- **Recommandation :** Utiliser un petit hook useToday() qui programme un setTimeout jusqu'à minuit et se réarme.
|
||||||
|
|
||||||
|
### DASH-44
|
||||||
|
|
||||||
|
**Logs volumineux à chaque appel dans les chemins les plus sollicités** — perf-serveur, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:3263`
|
||||||
|
- **Constat :** routes.ts:1353 'console.log('Orders API called with:', ...)' (plus 'Fetching all orders' et 'Orders returned'). l. 3263-3269 : 'taskGroups: tasks.map(t => ({ id, title, groupId })).slice(0, 3)', qui parcourt toute la liste juste pour un log. storage.ts:721 (getOrders), 1250 (stats), 2303-2313 (getTasks sampleTasks), routes.ts:5811 (contenu des annonces).
|
||||||
|
- **Impact :** Du CPU et de l'I/O synchrone (stdout) consommés à chaque requête, des logs illisibles qui noient les vraies erreurs, et du contenu métier écrit dans les logs.
|
||||||
|
- **Recommandation :** Supprimer ces console.log ou les placer derrière un logger à niveau (DEBUG=logiflow:*). Ne garder que les erreurs et le log d'accès existant (index.production.ts:30-38).
|
||||||
|
|
||||||
|
### DASH-45
|
||||||
|
|
||||||
|
**Code mort : RecentTasksCard, ResponsiveDashboard, server/cache.ts, imports et variables inutilisés** — dette-code, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/tasks/RecentTasksCard.tsx:25`
|
||||||
|
- **Constat :** grep : RecentTasksCard et ResponsiveDashboard ne sont importés nulle part. RecentTasksCard.tsx:38 'fetch(`/api/tasks?...`).then(res => res.json())' sans credentials ni contrôle res.ok. server/cache.ts (cacheMiddleware, setupCompression) n'est importé nulle part. Dashboard.tsx:9 importe ShoppingCart, TrendingUp, MapPin, CheckCircle, FileText et Shield, et l. 5 CardDescription, sans les utiliser. Variables recentOrders, ordersByStatus et customerOrderStats inutilisées. Commentaire trompeur l. 649 'Section Rapprochement BL' au-dessus des informations et des tâches.
|
||||||
|
- **Impact :** Confusion lors de la maintenance (trois implémentations de la carte des tâches), et risque de réutiliser par erreur un composant défectueux.
|
||||||
|
- **Recommandation :** Supprimer RecentTasksCard.tsx, ResponsiveDashboard.tsx et server/cache.ts (ou le réécrire proprement), ainsi que les imports, variables et commentaires obsolètes du Dashboard.
|
||||||
|
|
||||||
|
### DASH-46
|
||||||
|
|
||||||
|
**Configurations de priorités et de types d'information dupliquées, avec des correspondances contre-intuitives** — dette-code, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Dashboard.tsx:139`
|
||||||
|
- **Constat :** Dashboard.tsx:139-171 getAnnouncementPriorityConfig est un copier-coller d'AnnouncementCard.tsx:66-98 ('error' -> 'Nouveauté' violet, 'success' -> 'Important' rouge). getPriorityConfig est dupliqué dans Dashboard.tsx:341-368 et RecentTasksCard.tsx:76-103 (commentaire 'identique au module Tasks').
|
||||||
|
- **Impact :** Toute évolution des couleurs ou libellés doit être faite à 3 ou 4 endroits, d'où des incohérences visuelles entre pages. Les correspondances type -> libellé ('error' = Nouveauté) induisent les développeurs en erreur.
|
||||||
|
- **Recommandation :** Auto : extraire dans client/src/lib/statusConfig.ts les configurations identiques (types d'annonce : Dashboard et AnnouncementCard ; priorités de tâches avec variante Badge : Dashboard, Tasks.tsx, RecentTasksCard s'il est conservé), sans modifier aucun libellé, couleur ni valeur. Ne pas fusionner la variante mobile (classes CSS) et ne pas faire de migration des valeurs de type.
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
# Calendrier, Commandes, Livraisons
|
||||||
|
|
||||||
|
_58 constats vérifiés — 7 haute, 32 moyenne, 19 basse._
|
||||||
|
|
||||||
|
## Pages analysées
|
||||||
|
|
||||||
|
### `/calendar (desktop)`
|
||||||
|
|
||||||
|
**Rôle :** Vue mensuelle des commandes fournisseurs, des livraisons prévues/reçues et des publicités du magasin sélectionné, avec un panneau de statistiques du mois. C'est le point d'entrée pour planifier et ouvrir la fiche d'une commande ou d'une livraison.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir ce qui est commandé et livré chaque jour du mois
|
||||||
|
- Passer au mois précédent ou suivant
|
||||||
|
- Créer une commande ou une livraison à une date
|
||||||
|
- Ouvrir la fiche d'une commande ou livraison (modifier, valider, supprimer)
|
||||||
|
- Lire le commentaire d'une livraison
|
||||||
|
- Consulter les chiffres du mois (commandes, livraisons, palettes, colis)
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/orders?startDate&endDate&storeId | au montage, à chaque changement de mois ou de magasin (fetch() manuel dans queryFn) | server/routes.ts:1343 -> storage.getOrdersByDateRange (storage.ts:725) + loadDeliveriesByOrderIds (storage.ts:522) | Charge chaque commande avec group complet (logo base64 compris), supplier complet, creator, et toutes les livraisons liées, elles-mêmes avec group/supplier/creator complets. Plage limitée à startOfMonth..endOfMonth alors que la grille affiche 42 jours. |
|
||||||
|
| GET /api/deliveries?startDate&endDate&storeId | au montage, à chaque changement de mois ou de magasin | server/routes.ts:1752 -> storage.getDeliveriesByDateRange (storage.ts:939) + attachOrdersAndCommentCounts (loadOrdersByIds + loadReconciliationCommentCounts en Promise.all) | Pas de N+1 (regroupement en 2 requêtes), mais colonnes lourdes : group.* (logo), order complet imbriqué. |
|
||||||
|
| GET /api/ad-campaigns?year&storeId(admin seulement) | au montage et au changement d'année ou de magasin | server/routes.ts:4595 -> storage.getPublicities (storage.ts:1502) | Le paramètre groupIds est ignoré côté storage : toute l'année, tous magasins, participations avec group complet. Le filtrage par magasin et par jour se fait en JS, 42 fois par rendu. |
|
||||||
|
| GET /api/stats/monthly?year&month&storeId | au montage de StatsPanel et au changement de mois | server/routes.ts:4155 -> storage.getMonthlyStats (storage.ts:1222) | 4 requêtes SQL séquentielles. Libellés 'Colis' et 'Palettes' faux. Données fictives si NODE_ENV=development. Clé de cache [statsUrl, storeId] jamais touchée par les invalidations ['/api/stats/monthly']. |
|
||||||
|
| GET /api/groups | Layout au montage (clé partagée avec les modales) | server/routes.ts:970 -> storage.getGroups (admin, toutes colonnes dont logo) ou user.userGroups | Rechargé à chaque ouverture de modale une fois passé le staleTime de 30 s. |
|
||||||
|
| GET /api/suppliers, GET /api/orders (complet) | à l'ouverture de CreateOrderModal ou de CreateDeliveryModal | server/routes.ts:1141 -> storage.getSuppliers ; server/routes.ts:1343 -> storage.getOrders (storage.ts:678) | CreateDeliveryModal télécharge tout l'historique des commandes pour remplir une liste déroulante. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** La page est dense et difficile à lire. Le mois apparaît deux fois (en-tête et barre de navigation). La légende (bleu primaire, vert foncé, gris) ne correspond pas aux couleurs de la grille (bleu clair, jaune pour 'planifié', vert clair, violet pour les pubs). Les éléments sont écrits en 11 px avec des abréviations (P/C) et des icônes de 8 px. Seuls 2 éléments sont visibles par jour, le reste passe par '+N autres', ce qui ouvre des modales imbriquées. Un clic n'importe où dans une case ouvre 'Création rapide', même pour un utilisateur sans droits. Le bouton '+' n'apparaît qu'au survol. Le panneau de statistiques, fixé en bas à droite, recouvre les dernières cases et affiche des chiffres faux. Un spinner plein écran remplace la grille à chaque changement de mois. Les jours des mois voisins sont affichés mais toujours vides. Le passage au mois suivant saute un mois les 29, 30 et 31. Il n'y a aucun état d'erreur : un échec d'API donne un calendrier vide silencieux.
|
||||||
|
|
||||||
|
### `/calendar (mobile)`
|
||||||
|
|
||||||
|
**Rôle :** Calendrier mensuel simplifié pour téléphone : des points de couleur indiquent les jours avec des commandes ou des livraisons, et la liste du jour sélectionné s'affiche dessous.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir les jours avec commandes et livraisons
|
||||||
|
- Voir la liste des événements d'un jour
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/orders?startDate&endDate&storeId | au montage et au changement de mois (enabled: !!user) | server/routes.ts:1343 -> storage.getOrdersByDateRange (storage.ts:725) | Clé ['/api/orders', storeId, 'yyyy-MM'] différente de celle du desktop pour la même donnée. Les erreurs sont avalées (return []). |
|
||||||
|
| GET /api/deliveries?startDate&endDate&storeId | au montage et au changement de mois | server/routes.ts:1752 -> storage.getDeliveriesByDateRange (storage.ts:939) | Même charge utile lourde que sur desktop pour n'afficher qu'un titre et un statut. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** La page est lisible mais en lecture seule : les cartes ne sont pas cliquables, et on ne peut ni ouvrir une fiche, ni valider, ni créer. Le statut s'affiche brut en anglais ('pending', 'planned', 'delivered'). Les titres 'Cmd #12' et 'Liv #34' sont techniques (orderNumber n'existe pas dans le schéma). Les jours des semaines voisines sont affichés sans données. Il n'y a ni état de chargement ni état d'erreur.
|
||||||
|
|
||||||
|
### `/orders (desktop)`
|
||||||
|
|
||||||
|
**Rôle :** Liste de toutes les commandes fournisseurs du magasin, avec recherche, filtre par statut, pagination, création, modification et suppression.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Retrouver une commande (fournisseur, statut)
|
||||||
|
- Créer une commande
|
||||||
|
- Modifier une commande
|
||||||
|
- Supprimer une commande
|
||||||
|
- Voir le détail et les livraisons liées
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/orders?storeId | au montage et au changement de magasin (fetch() manuel, clé [ordersUrl, storeId]) | server/routes.ts:1343 (branche sans dates, 1372/1450) -> storage.getOrders (storage.ts:678) + loadDeliveriesByOrderIds (storage.ts:522) | Tout l'historique sans LIMIT, puis pagination côté client par 20. Pour un admin en 'tous magasins', ce sont toutes les commandes de tous les magasins. |
|
||||||
|
| GET /api/groups | au montage | server/routes.ts:970 -> storage.getGroups / user.userGroups | Résultat jamais utilisé dans la page. |
|
||||||
|
| DELETE /api/orders/:id | au clic sur 'Supprimer définitivement' | server/routes.ts:1655 -> storage.getOrder (storage.ts:783, 2 requêtes) + storage.deleteOrder (storage.ts:880) | Après succès, invalidateQueries puis refetchQueries de toutes les variantes /api/orders et /api/deliveries, y compris celles qui sont inactives. |
|
||||||
|
| PUT /api/orders/:id | à l'enregistrement de EditOrderModal | server/routes.ts:1620 -> storage.getOrder + storage.updateOrder (storage.ts:871) | Jamais atteint : les arguments de apiRequest sont inversés côté client. |
|
||||||
|
| POST /api/orders, GET /api/suppliers | ouverture et validation de CreateOrderModal | server/routes.ts:1552 -> storage.createOrder ; server/routes.ts:1141 -> storage.getSuppliers | Liste fournisseurs complète, sans recherche. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Structure claire (en-tête, filtres, tableau), mais plusieurs défauts. Le libellé 'Groupe' apparaît alors que la navigation dit 'Magasin'. L'identifiant technique '#id' est affiché. Les boutons d'action sont des icônes sans libellé ni info-bulle. Le tableau est en min-w 700px dans un conteneur overflow-hidden, donc tronqué sans défilement sur petit écran. Les couleurs de statut diffèrent du calendrier. Il n'y a ni tri ni filtre de période. La pagination revient à la page 1 après une suppression. La modification d'une commande échoue systématiquement (bug apiRequest). La suppression d'une commande liée à des livraisons renvoie un message générique. Les hooks sont appelés après un return anticipé. Les logs de debug s'exécutent par ligne à chaque frappe.
|
||||||
|
|
||||||
|
### `/orders (mobile)`
|
||||||
|
|
||||||
|
**Rôle :** Liste des commandes en cartes avec recherche et onglets Toutes, En attente, Livrées.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Rechercher une commande
|
||||||
|
- Filtrer par statut
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/orders?storeId | au montage (enabled: !!user) | server/routes.ts:1343 -> storage.getOrders (storage.ts:678) | Tout l'historique, tout rendu sans pagination ni virtualisation. Erreurs avalées (return []). |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Les cartes affichent une flèche '>' mais ne sont pas cliquables, et aucune action n'est possible (icône Plus importée mais jamais utilisée). La date est toujours 'Date inconnue' (champ orderDate inexistant). La recherche par numéro ne fonctionne jamais (orderNumber inexistant). Le statut 'planned' s'affiche brut en anglais. L'accès n'est pas restreint pour les employés, alors que la version desktop les bloque. Une erreur serveur s'affiche comme 'Aucune commande trouvée'.
|
||||||
|
|
||||||
|
### `/deliveries (desktop)`
|
||||||
|
|
||||||
|
**Rôle :** Liste des livraisons du magasin : planifier, modifier, valider la réception (saisie du n° de BL), marquer le contrôle qualité, supprimer.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir les livraisons à recevoir
|
||||||
|
- Valider une livraison reçue avec le n° de BL
|
||||||
|
- Marquer le contrôle comme effectué
|
||||||
|
- Créer ou modifier une livraison et la lier à une commande
|
||||||
|
- Supprimer une livraison
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/deliveries?storeId | au montage et au changement de magasin (clé ['/api/deliveries', storeId, user.role]) | server/routes.ts:1752 (branche sans dates 1781/1859) -> storage.getDeliveries (storage.ts:885) + attachOrdersAndCommentCounts | Tout l'historique sans LIMIT, pagination côté client. Filtre withBL fait en JS (routes.ts:1864). |
|
||||||
|
| GET /api/groups | au montage | server/routes.ts:970 | Résultat jamais utilisé. |
|
||||||
|
| POST /api/deliveries/:id/validate | ValidateDeliveryModal | server/routes.ts:2604 -> storage.getDelivery (storage.ts:1004 : 4-6 requêtes séquentielles dont getOrder) + validateDelivery + updateOrder | Le contrôle d'accès au magasin n'est fait que pour le rôle manager. |
|
||||||
|
| PUT /api/deliveries/:id/control | au clic sur l'icône CheckCircle orange, sans confirmation | server/routes.ts:2678 -> storage.getDelivery + markDeliveryControlValidated | Aucun contrôle d'appartenance au magasin. |
|
||||||
|
| DELETE /api/deliveries/:id | ConfirmDeleteModal | server/routes.ts:2136 -> storage.getDelivery + deleteDelivery | Côté client, /api/orders n'est pas invalidé alors que les commandes embarquent leurs livraisons. |
|
||||||
|
| POST /api/deliveries, PUT /api/deliveries/:id, GET /api/orders (complet), GET /api/suppliers | modales de création et de modification | server/routes.ts:2072 / 1906 / 1343 -> storage.createDelivery / updateDelivery / getOrders | Tout l'historique des commandes est chargé pour la liste 'Commande liée'. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Le tableau a 8 colonnes, n'est pas responsive (overflow-hidden, en-tête non adapté au mobile) et ses en-têtes ont deux styles différents (xs gris et sm font-black). Jusqu'à 5 icônes d'action sans libellé, dont deux coches proches pour 'valider la livraison' et 'contrôle effectué'. Le contrôle se valide en un clic sans confirmation. Le filtre 'En attente' ne correspond à aucun statut existant (planned/delivered). Les abréviations 'P' et 'C' sont utilisées pour les quantités. Le libellé 'Groupe' est utilisé à la place de 'Magasin'. Le padding est doublé (p-6 imbriqués). On ne peut pas rechercher par n° de BL. Des logs de debug sont présents.
|
||||||
|
|
||||||
|
### `/deliveries (mobile)`
|
||||||
|
|
||||||
|
**Rôle :** Liste des livraisons en cartes avec recherche et onglets Toutes, En attente, Reçues.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Voir les livraisons du jour ou en retard
|
||||||
|
- Filtrer les livraisons reçues
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/deliveries?storeId | au montage | server/routes.ts:1752 -> storage.getDeliveries (storage.ts:885) | Tout l'historique, rendu intégral, erreurs avalées. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** La page est inutilisable en l'état. expectedDate et deliveryNumber n'existent pas : toutes les dates affichent 'Date inconnue' et les badges 'Aujourd'hui' ou 'En retard' n'apparaissent jamais. Le statut 'received' n'existe pas : l'onglet 'Reçues' est toujours vide et 'En attente' contient aussi les livraisons livrées. L'icône de statut est toujours générique. Aucune action n'est possible : impossible de valider une réception depuis le téléphone, alors que c'est la tâche principale en magasin.
|
||||||
|
|
||||||
|
### `Modales partagées (Calendrier / Commandes / Livraisons)`
|
||||||
|
|
||||||
|
**Rôle :** Formulaires de création, modification, validation, détail et confirmation de suppression des commandes et livraisons.
|
||||||
|
|
||||||
|
**Tâches principales de l'utilisateur :**
|
||||||
|
- Créer ou modifier une commande ou une livraison
|
||||||
|
- Lier une livraison à une commande
|
||||||
|
- Valider une livraison (n° de BL)
|
||||||
|
- Consulter une fiche et supprimer
|
||||||
|
|
||||||
|
**Appels API :**
|
||||||
|
|
||||||
|
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/suppliers | montage de chaque modale de formulaire | server/routes.ts:1141 -> storage.getSuppliers (storage.ts:467) | Refetch à chaque ouverture après 30 s. |
|
||||||
|
| GET /api/groups | montage de chaque modale de formulaire | server/routes.ts:970 | Admin : toutes les colonnes, logo compris. |
|
||||||
|
| GET /api/orders (sans paramètre) | montage de CreateDeliveryModal et EditDeliveryModal (queryFn par défaut) | server/routes.ts:1343 -> storage.getOrders (storage.ts:678) | Tout l'historique pour filtrer en JS par fournisseur, magasin et statut. |
|
||||||
|
| PUT /api/orders/:id | EditOrderModal et le bouton 'Valider Commande' de OrderDetailModal | server/routes.ts:1620 | Cassé depuis EditOrderModal (arguments inversés). |
|
||||||
|
| POST /api/deliveries/:id/validate | ValidateDeliveryModal | server/routes.ts:2604 | Le BL est obligatoire. Invalidation de stats inefficace. |
|
||||||
|
|
||||||
|
**Lisibilité / simplicité :** Les formulaires sont courts et clairs. En revanche, la liste fournisseurs n'a pas de recherche. Un admin en 'tous magasins' ne peut pas choisir le magasin : le premier est imposé, en lecture seule. Le libellé 'Magasin/Groupe' est ambigu. Les boutons de pied de modale sont alignés tantôt à gauche, tantôt à droite, avec des couleurs incohérentes. La fiche détail utilise un en-tête bleu même pour une livraison, une zone max-h-96 et une modale d'édition empilée par-dessus ; après modification, elle affiche des données périmées. Elle a aussi sa propre confirmation de suppression au lieu de ConfirmDeleteModal. EditDeliveryModal masque la commande liée si celle-ci est livrée. La gestion de session expirée ne fonctionne pas (regex jamais vraie, redirection vers GET /api/login inexistant). Les logs s'exécutent à chaque rendu.
|
||||||
|
|
||||||
|
## Constats
|
||||||
|
|
||||||
|
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| [MOB-01](#mob-01) | haute | ux-simplicite | L | Versions mobiles en lecture seule : impossible d'ouvrir une fiche ou de valider une réception | `client/src/pages/mobile/DeliveriesPage.tsx:163` |
|
||||||
|
| [MOB-02](#mob-02) | haute | bug | S | Pages mobiles basées sur des champs et statuts inexistants : dates, numéros et filtres cassés | `client/src/pages/mobile/DeliveriesPage.tsx:29` |
|
||||||
|
| [MOD-01](#mod-01) | haute | perf-api | M | Les modales de livraison téléchargent toutes les commandes pour remplir une liste déroulante | `client/src/components/modals/CreateDeliveryModal.tsx:60` |
|
||||||
|
| [ORD-01](#ord-01) | haute | bug | S | Modification de commande toujours en échec : arguments de apiRequest inversés | `client/src/components/modals/EditOrderModal.tsx:61` |
|
||||||
|
| [ORD-02](#ord-02) | haute | perf-api | L | Historique complet chargé sans limite, puis pagination côté navigateur | `client/src/pages/Orders.tsx:73` |
|
||||||
|
| [SRV-01](#srv-01) | haute | perf-api | S | Logo base64 et configuration SMTP/NocoDB du magasin renvoyés dans chaque ligne de commande ou livraison | `server/storage.ts:693` |
|
||||||
|
| [SRV-03](#srv-03) | haute | perf-api | S | Aucune compression HTTP, et les assets hashés sont servis sans cache long | `server/index.production.ts:27` |
|
||||||
|
| [BUNDLE-01](#bundle-01) | moyenne | perf-bundle | M | Toutes les pages, desktop et mobile, sont importées statiquement : bundle initial d'environ 1,5 Mo | `client/src/components/RouterProduction.tsx:5` |
|
||||||
|
| [CAL-01](#cal-01) | moyenne | bug | S | Le bouton 'mois suivant' saute un mois quand on est le 29, 30 ou 31 | `client/src/pages/Calendar.tsx:171` |
|
||||||
|
| [CAL-02](#cal-02) | moyenne | perf-client | S | Calculs de debug exécutés en production dans la boucle de rendu | `client/src/components/CalendarGrid.tsx:495` |
|
||||||
|
| [CAL-03](#cal-03) | moyenne | perf-client | M | Filtrage O(42 × éléments) à chaque rendu, sans mémoïsation | `client/src/components/CalendarGrid.tsx:579` |
|
||||||
|
| [CAL-04](#cal-04) | moyenne | perf-client | S | Spinner plein écran à chaque changement de mois, et la grille attend les publicités | `client/src/pages/Calendar.tsx:204` |
|
||||||
|
| [CAL-06](#cal-06) | moyenne | coherence-design | S | La légende ne correspond pas aux couleurs utilisées dans la grille | `client/src/pages/Calendar.tsx:224` |
|
||||||
|
| [CAL-07](#cal-07) | moyenne | lisibilite | M | Éléments du calendrier illisibles : texte de 11 px, icônes de 8 px, abréviations P/C, statut codé par des points | `client/src/components/CalendarGrid.tsx:75` |
|
||||||
|
| [CAL-08](#cal-08) | moyenne | ux-simplicite | S | Un clic n'importe où dans une case ouvre 'Création rapide', même sans droits, et le '+' n'est visible qu'au survol | `client/src/pages/Calendar.tsx:181` |
|
||||||
|
| [CAL-10](#cal-10) | moyenne | ux-simplicite | S | Le panneau de statistiques en position fixe masque le bas du calendrier | `client/src/components/StatsPanel.tsx:54` |
|
||||||
|
| [CAL-11](#cal-11) | moyenne | bug | M | Panneau 'Statistiques du mois' : chiffres Palettes, Colis et 'en attente' faux | `server/storage.ts:1297` |
|
||||||
|
| [CAL-15](#cal-15) | moyenne | perf-serveur | S | getPublicities ignore le filtre magasin, charge des groupes complets et regroupe en O(P×N) | `server/storage.ts:1502` |
|
||||||
|
| [CAL-16](#cal-16) | moyenne | dette-code | M | fetch() manuels dupliqués : aucune gestion du 401 et aucun état d'erreur affiché | `client/src/pages/Calendar.tsx:62` |
|
||||||
|
| [LIV-01](#liv-01) | moyenne | bug | S | Le filtre 'En attente' des livraisons ne correspond à aucun statut existant | `client/src/pages/Deliveries.tsx:325` |
|
||||||
|
| [LIV-02](#liv-02) | moyenne | ux-simplicite | S | 'Contrôle effectué' validé en un clic, sans confirmation | `client/src/pages/Deliveries.tsx:484` |
|
||||||
|
| [MCAL-01](#mcal-01) | moyenne | lisibilite | S | Statuts affichés bruts en anglais et titres techniques 'Cmd #' / 'Liv #' | `client/src/pages/mobile/CalendarPage.tsx:293` |
|
||||||
|
| [MOB-03](#mob-03) | moyenne | ux-simplicite | S | Les erreurs serveur sont présentées comme 'Aucune commande trouvée' | `client/src/pages/mobile/OrdersPage.tsx:51` |
|
||||||
|
| [MOD-02](#mod-02) | moyenne | bug | S | Gestion de session expirée inopérante : regex jamais vraie et redirection vers une route inexistante | `client/src/lib/authUtils.ts:2` |
|
||||||
|
| [MOD-03](#mod-03) | moyenne | ux-simplicite | S | Un admin en 'Tous les magasins' ne peut pas choisir le magasin : le premier est imposé | `client/src/components/modals/CreateOrderModal.tsx:84` |
|
||||||
|
| [MOD-04](#mod-04) | moyenne | ux-simplicite | M | Choix du fournisseur dans une longue liste sans recherche | `client/src/components/modals/CreateOrderModal.tsx:188` |
|
||||||
|
| [ORD-03](#ord-03) | moyenne | perf-client | S | Log par ligne à chaque frappe et filtrage sans mémoïsation | `client/src/pages/Orders.tsx:169` |
|
||||||
|
| [ORD-05](#ord-05) | moyenne | bug | M | Invalidations qui ratent leur cible : clés de cache hétérogènes pour les mêmes données | `client/src/pages/Orders.tsx:76` |
|
||||||
|
| [ORD-06](#ord-06) | moyenne | perf-client | S | La suppression recharge toutes les variantes en cache, y compris les inactives (invalidate puis refetch) | `client/src/pages/Orders.tsx:141` |
|
||||||
|
| [ORD-10](#ord-10) | moyenne | accessibilite | S | Actions en icônes seules, sans libellé ni info-bulle, et deux coches ambiguës | `client/src/pages/Deliveries.tsx:453` |
|
||||||
|
| [ORD-11](#ord-11) | moyenne | ux-simplicite | S | Tableaux tronqués sur écrans étroits : pas de défilement horizontal | `client/src/pages/Orders.tsx:316` |
|
||||||
|
| [ORD-12](#ord-12) | moyenne | coherence-design | M | Couleurs et libellés de statut différents selon les pages, et couleurs des boutons principaux incohérentes | `client/src/pages/Orders.tsx:190` |
|
||||||
|
| [ORD-13](#ord-13) | moyenne | ux-simplicite | M | La suppression d'une commande liée à des livraisons échoue avec un message générique | `server/storage.ts:880` |
|
||||||
|
| [PERM-01](#perm-01) | moyenne | dette-code | M | Deux matrices de permissions divergentes entre client et serveur | `client/src/lib/permissions.ts:1` |
|
||||||
|
| [SRV-02](#srv-02) | moyenne | perf-api | M | Imbrications redondantes : livraisons complètes dans chaque commande et commande complète dans chaque livraison | `server/storage.ts:522` |
|
||||||
|
| [SRV-04](#srv-04) | moyenne | perf-serveur | M | Utilisateur et magasins rechargés deux fois par requête API | `server/localAuth.production.ts:195` |
|
||||||
|
| [SRV-05](#srv-05) | moyenne | perf-serveur | S | Toutes les réponses JSON sont clonées récursivement pour retirer smtpPassword | `server/routes.ts:168` |
|
||||||
|
| [SRV-07](#srv-07) | moyenne | bug | S | Manager ou directeur multi-magasins sans sélection : seul le premier magasin est renvoyé, en silence | `server/routes.ts:1419` |
|
||||||
|
| [SRV-08](#srv-08) | moyenne | bug | S | Contrôles d'accès par magasin incomplets (validation, contrôle, lecture, statistiques) | `server/routes.ts:2624` |
|
||||||
|
| [CAL-05](#cal-05) | basse | bug | S | Les jours des mois voisins sont affichés mais toujours vides | `client/src/pages/Calendar.tsx:33` |
|
||||||
|
| [CAL-12](#cal-12) | basse | perf-serveur | S | Statistiques mensuelles : 4 requêtes séquentielles, sans cache | `server/storage.ts:1270` |
|
||||||
|
| [CAL-13](#cal-13) | basse | bug | S | La fenêtre '+X autres' affiche la date de création des commandes au lieu de la date prévue | `client/src/components/CalendarGrid.tsx:324` |
|
||||||
|
| [CAL-14](#cal-14) | basse | ux-simplicite | M | Seulement 2 éléments par jour, et des modales imbriquées avec des contournements de z-index et d'état | `client/src/components/CalendarGrid.tsx:177` |
|
||||||
|
| [CAL-17](#cal-17) | basse | perf-client | S | Double chargement au démarrage : les requêtes partent avant la sélection automatique du magasin | `client/src/components/Layout.tsx:70` |
|
||||||
|
| [MOD-05](#mod-05) | basse | perf-client | S | useEffect et logs exécutés à chaque rendu (dépendance recréée) | `client/src/components/modals/CreateOrderModal.tsx:49` |
|
||||||
|
| [MOD-06](#mod-06) | basse | perf-client | S | Données de référence rechargées à chaque ouverture de modale | `client/src/lib/queryClient.ts:77` |
|
||||||
|
| [MOD-07](#mod-07) | basse | dette-code | S | Mutation morte, confirmation de suppression dupliquée et rôles codés en dur | `client/src/components/modals/OrderDetailModal.tsx:70` |
|
||||||
|
| [MOD-08](#mod-08) | basse | ux-simplicite | M | Modale d'édition empilée sur la fiche, données périmées après modification, contenu limité à 384 px | `client/src/components/modals/OrderDetailModal.tsx:437` |
|
||||||
|
| [MOD-09](#mod-09) | basse | coherence-design | S | Pieds de modale et boutons d'action incohérents | `client/src/components/modals/CreateOrderModal.tsx:245` |
|
||||||
|
| [MOD-10](#mod-10) | basse | ux-simplicite | S | Le champ 'Commande liée' disparaît si la commande liée est déjà livrée | `client/src/components/modals/EditDeliveryModal.tsx:191` |
|
||||||
|
| [ORD-04](#ord-04) | basse | perf-client | S | Requête /api/groups inutilisée | `client/src/pages/Orders.tsx:101` |
|
||||||
|
| [ORD-07](#ord-07) | basse | bug | S | Hooks appelés après un return anticipé (règle des hooks violée) | `client/src/pages/Orders.tsx:41` |
|
||||||
|
| [ORD-08](#ord-08) | basse | lisibilite | S | 'Groupe' et 'Magasin/Groupe' au lieu de 'Magasin' | `client/src/pages/Orders.tsx:323` |
|
||||||
|
| [ORD-09](#ord-09) | basse | lisibilite | S | Identifiants techniques affichés sous des formes différentes selon l'écran | `client/src/components/modals/OrderDetailModal.tsx:209` |
|
||||||
|
| [ORD-14](#ord-14) | basse | ux-simplicite | S | Retour à la page 1 après chaque suppression, et confirmation fermée avant la fin de l'action | `client/src/components/ui/pagination.tsx:157` |
|
||||||
|
| [ORD-15](#ord-15) | basse | ux-simplicite | M | Pas de tri, pas de filtre de période, recherche sans n° de BL | `client/src/pages/Deliveries.tsx:176` |
|
||||||
|
| [SRV-06](#srv-06) | basse | perf-serveur | S | Logs console à chaque appel des chemins chauds | `server/routes.ts:1353` |
|
||||||
|
| [SRV-09](#srv-09) | basse | perf-serveur | S | Le filtre withBL est appliqué en JavaScript après le chargement complet | `server/routes.ts:1864` |
|
||||||
|
|
||||||
|
### MOB-01
|
||||||
|
|
||||||
|
**Versions mobiles en lecture seule : impossible d'ouvrir une fiche ou de valider une réception** — ux-simplicite, sévérité haute, effort L
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DeliveriesPage.tsx:163`
|
||||||
|
- **Constat :** DeliveriesPage.tsx:163-195 `<Card key={delivery.id}>` sans onClick, mais avec `<ChevronRight>` (192) qui suggère un lien. OrdersPage.tsx:146-177 idem : `Plus` est importé (16) et jamais utilisé, aucun bouton de création. CalendarPage.tsx:266-300 : cartes d'événement non cliquables.
|
||||||
|
- **Impact :** L'employé qui réceptionne un camion avec son téléphone ne peut ni valider la livraison ni saisir le BL. Il doit passer sur un poste fixe. La flèche affichée trompe l'utilisateur, qui touche sans résultat.
|
||||||
|
- **Recommandation :** Rendre les cartes cliquables et ouvrir une fiche plein écran (Sheet) qui réutilise OrderDetailModal. Ajouter sur chaque livraison 'planned' un bouton visible 'Valider la réception' qui ouvre ValidateDeliveryModal. Ajouter un bouton flottant '+' pour créer (selon les permissions). Retirer le chevron tant que la carte n'est pas cliquable.
|
||||||
|
|
||||||
|
### MOB-02
|
||||||
|
|
||||||
|
**Pages mobiles basées sur des champs et statuts inexistants : dates, numéros et filtres cassés** — bug, sévérité haute, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/DeliveriesPage.tsx:29`
|
||||||
|
- **Constat :** DeliveriesPage.tsx:27-35 type `{ deliveryNumber?, expectedDate, status }`, ligne 185 `delivery.expectedDate && ... : 'Date inconnue'`, lignes 41/63-64 filtre `'received'`. OrdersPage.tsx:25-33 `{ orderNumber?, orderDate }`, lignes 167-170 `order.orderDate ... : 'Date inconnue'`. Or shared/schema.ts:121-133 (orders : plannedDate, aucun orderNumber/orderDate) et 136-146 (deliveries : scheduledDate, status 'planned, delivered'). Aucune occurrence de orderNumber, deliveryNumber ou expectedDate côté serveur (grep). CalendarPage.tsx:105/113 `Cmd #${order.orderNumber || order.id}`.
|
||||||
|
- **Impact :** Sur téléphone, toutes les commandes et livraisons affichent 'Date inconnue'. Les badges 'Aujourd'hui' et 'En retard' n'apparaissent jamais. L'onglet 'Reçues' est toujours vide et 'En attente' inclut les livraisons déjà livrées. La recherche par numéro ne trouve rien. Le statut 'planned' s'affiche brut.
|
||||||
|
- **Recommandation :** Correction minimale et mécanique : expectedDate→scheduledDate, orderDate→plannedDate, filtre 'received'→'delivered' (et 'pending'→status==='planned' pour les livraisons), ajout d'un badge 'planned' ('Planifiée'), suppression de orderNumber/deliveryNumber (afficher l'id). Mutualiser les libellés avec le desktop est un chantier à part (voir ORD-12).
|
||||||
|
|
||||||
|
### MOD-01
|
||||||
|
|
||||||
|
**Les modales de livraison téléchargent toutes les commandes pour remplir une liste déroulante** — perf-api, sévérité haute, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/CreateDeliveryModal.tsx:60`
|
||||||
|
- **Constat :** CreateDeliveryModal.tsx:60-62 `useQuery<OrderWithRelations[]>({ queryKey: ['/api/orders'] })` passe par la queryFn par défaut, soit GET /api/orders sans storeId ni dates, donc storage.getOrders() complet avec livraisons imbriquées. Le résultat est ensuite filtré en JS (65-73) par fournisseur, magasin et statut. Même chose dans EditDeliveryModal.tsx:50-61.
|
||||||
|
- **Impact :** L'ouverture de 'Nouvelle livraison' ou 'Modifier livraison' déclenche le plus gros appel de l'application (toutes les commandes, tous magasins pour un admin). Le formulaire est lent et l'appel est refait à chaque ouverture après 30 s.
|
||||||
|
- **Recommandation :** Ajouter `GET /api/orders?storeId=&supplierId=&status=pending,planned&light=1` qui renvoie {id, plannedDate, status}. Ne lancer la requête qu'une fois le fournisseur choisi (`enabled: !!formData.supplierId`), avec une clé ['/api/orders', 'open', {storeId, supplierId}].
|
||||||
|
|
||||||
|
### ORD-01
|
||||||
|
|
||||||
|
**Modification de commande toujours en échec : arguments de apiRequest inversés** — bug, sévérité haute, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/EditOrderModal.tsx:61`
|
||||||
|
- **Constat :** EditOrderModal.tsx:61 `await apiRequest("PUT", `/api/orders/${order?.id}`, data);` alors que queryClient.ts:10-15 définit `apiRequest(url: string, method: string = 'GET', body?)`. Le résultat est fetch('PUT', { method: '/api/orders/12' }), qui lève un TypeError (méthode HTTP invalide). C'est le seul appel de ce type dans client/src (grep).
|
||||||
|
- **Impact :** Aucune commande ne peut être modifiée, ni depuis la page Commandes ni depuis la fiche ouverte dans le calendrier. L'utilisateur voit seulement 'Impossible de modifier la commande'.
|
||||||
|
- **Recommandation :** Remplacer par `await apiRequest(`/api/orders/${order?.id}`, 'PUT', data);`. Si l'on type method, utiliser `type HttpMethod = 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'` pour ne casser aucun appelant existant.
|
||||||
|
|
||||||
|
### ORD-02
|
||||||
|
|
||||||
|
**Historique complet chargé sans limite, puis pagination côté navigateur** — perf-api, sévérité haute, effort L
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:73`
|
||||||
|
- **Constat :** Orders.tsx:73 `const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;` appelle routes.ts:1372/1450 `storage.getOrders(groupIds)` (storage.ts:678-723 : aucun LIMIT, plus toutes les livraisons liées). Ensuite, Orders.tsx:180-188 `usePagination(filteredOrders, 20)`. Même chose pour Deliveries.tsx:80 vers getDeliveries (885-937), et pour OrdersPage.tsx:43-48 et DeliveriesPage.tsx:45-50 sur mobile, qui rendent tout sans pagination.
|
||||||
|
- **Impact :** Le temps de chargement grandit sans fin avec l'historique. Un admin en 'Tous les magasins' télécharge toutes les commandes de tous les magasins pour n'en voir que 20. La mémoire et le rendu mobile se dégradent.
|
||||||
|
- **Recommandation :** Créer un endpoint paginé `GET /api/orders?storeId&page&limit&status&search&from&to` qui renvoie { items, total }. Faire le filtre statut, la recherche (ILIKE sur suppliers.name, notes, bl_number) et le tri en SQL, avec par défaut les 3 derniers mois. Côté client : useQuery avec placeholderData: keepPreviousData, ou useInfiniteQuery sur mobile.
|
||||||
|
|
||||||
|
### SRV-01
|
||||||
|
|
||||||
|
**Logo base64 et configuration SMTP/NocoDB du magasin renvoyés dans chaque ligne de commande ou livraison** — perf-api, sévérité haute, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:693`
|
||||||
|
- **Constat :** storage.ts:693, 740, 913, 967, 553, 599 : `group: groups,` sélectionne toutes les colonnes de groups, dont shared/schema.ts:67 `logo: text("logo"), // Logo en data URI (data:image/png;base64,...)`, smtpHost, smtpUser, smtpSenderEmail, address, phone, nocodb*. Le groupe est répété pour chaque commande (getOrders), pour chaque livraison imbriquée dans une commande (loadDeliveriesByOrderIds:553) et pour chaque commande imbriquée dans une livraison (loadOrdersByIds:599). Même chose pour les participations de getPublicities (1536). Les pages n'utilisent que group.name et group.color, et BLReconciliation.tsx:298-325 utilise nocodbTableName, nocodbConfigId et webhookUrl.
|
||||||
|
- **Impact :** Avec un logo de quelques dizaines de Ko, la liste des commandes pèse plusieurs Mo, voire des dizaines. Le chargement des pages Calendrier, Commandes et Livraisons, ainsi que des modales de livraison, devient lent, surtout sur mobile. Des données de configuration inutiles sont aussi exposées au navigateur.
|
||||||
|
- **Recommandation :** Projeter group: {id, name, color, nocodbConfigId, nocodbTableName, webhookUrl} (sans logo ni smtp*) et supplier: {id, name, email, requiresControl, hasDlc, automaticReconciliation}. Créer des types dédiés (GroupSummary, SupplierSummary) dans OrderWithRelations et DeliveryWithRelations, puis vérifier tous les consommateurs (BLReconciliation, ReconciliationModal, Avoirs, CustomerOrders, Dashboard, exports serveur) avant de livrer.
|
||||||
|
|
||||||
|
### SRV-03
|
||||||
|
|
||||||
|
**Aucune compression HTTP, et les assets hashés sont servis sans cache long** — perf-api, sévérité haute, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/index.production.ts:27`
|
||||||
|
- **Constat :** index.production.ts:27-28 n'enregistre que `express.json` et `express.urlencoded`. Aucun middleware compression (absent de package.json) et aucune conf gzip dans le dépôt. Ligne 143 : `app.use('/assets', express.static(join(publicPath, 'assets')));` sans maxAge ni immutable. Le build actuel pèse 1 081 745 octets (index-CCl1yENM.js) + 410 243 octets (vendor-charts) + 120 775 octets de CSS.
|
||||||
|
- **Impact :** Les JSON de listes (déjà gonflés, voir SRV-01 et SRV-02) et environ 1,7 Mo de JS/CSS transitent non compressés. Les assets sont revalidés à chaque visite. Le premier affichage et chaque navigation sont nettement plus lents, surtout en 4G.
|
||||||
|
- **Recommandation :** Ajouter `compression` (et @types/compression) aux dépendances et `--external:compression` dans la commande esbuild du Dockerfile, puis `app.use(compression())` avant registerRoutes (index.production.ts et index.ts). Servir /assets avec `{ maxAge: '1y', immutable: true }` et envoyer index.html avec `Cache-Control: no-cache`. Vérifier d'abord la configuration gzip du nginx externe.
|
||||||
|
|
||||||
|
### BUNDLE-01
|
||||||
|
|
||||||
|
**Toutes les pages, desktop et mobile, sont importées statiquement : bundle initial d'environ 1,5 Mo** — perf-bundle, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/RouterProduction.tsx:5`
|
||||||
|
- **Constat :** RouterProduction.tsx:5-44 : environ 30 imports de pages en tête (Analytics, BackupManager, Utilities, BLReconciliation, DatabaseDebug, NocoDBConfig, pages mobiles…), sans React.lazy. Le build produit dist/public/assets/index-CCl1yENM.js (1 081 745 octets) et vendor-charts-gtSIDI7m.js (410 243 octets, recharts), tous deux chargés avant le premier affichage du Calendrier ou des Commandes.
|
||||||
|
- **Impact :** Le premier affichage est lent, surtout sur les postes de magasin et en 4G, et aggravé par l'absence de gzip (SRV-03).
|
||||||
|
- **Recommandation :** React.lazy par route avec <Suspense fallback={<PageSkeleton/>}>, en séparant les arbres desktop et mobile, ET ajouter `window.addEventListener('vite:preloadError', () => window.location.reload())` dans main.tsx, avec index.html servi en no-cache (voir SRV-03).
|
||||||
|
|
||||||
|
### CAL-01
|
||||||
|
|
||||||
|
**Le bouton 'mois suivant' saute un mois quand on est le 29, 30 ou 31** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:171`
|
||||||
|
- **Constat :** Calendar.tsx:171-179 `const newDate = new Date(currentDate); ... newDate.setMonth(newDate.getMonth() + 1);`. Le 31 octobre, cela donne le 31 novembre, qui n'existe pas, donc le 1er décembre : novembre est sauté. StatsPanel suit le même currentDate. La version mobile utilise correctement addMonths/subMonths (CalendarPage.tsx:148-151).
|
||||||
|
- **Impact :** Les utilisateurs qui ouvrent le calendrier en fin de mois ne peuvent pas afficher le mois suivant normalement. Les commandes de ce mois semblent absentes.
|
||||||
|
- **Recommandation :** Remplacer navigateMonth par `setCurrentDate(d => direction === 'prev' ? subMonths(startOfMonth(d), 1) : addMonths(startOfMonth(d), 1))`. Le bouton 'Aujourd'hui' est un ajout produit, à traiter séparément.
|
||||||
|
|
||||||
|
### CAL-02
|
||||||
|
|
||||||
|
**Calculs de debug exécutés en production dans la boucle de rendu** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/CalendarGrid.tsx:495`
|
||||||
|
- **Constat :** CalendarGrid.tsx:495-506 `console.log('📅 All orders dates:', orders.map(o => ({...})))` et 519-525 / 542-548 (log pour chaque élément trouvé, avec `format(date,'yyyy-MM-dd')`). vite.config.ts:29 retire l'appel console.log (`pure`) mais pas ses arguments. Le bundle prod contient bien `s.length>0&&v.getDate()===1&&(s.length,s[0],s.map(S=>{...plannedDate...}))`. Il reste aussi des console.warn dans les boucles (385, 402, 410, 511, 534), et des console.log non protégés par DEV aux lignes 444 et 452.
|
||||||
|
- **Impact :** Des tableaux et des formatages de dates sont créés inutilement à chaque rendu de la grille (42 cases). Le navigateur peut être inondé de warnings si une donnée est incomplète. La console est inutilisable pour le support.
|
||||||
|
- **Recommandation :** Supprimer ces blocs de debug (et ceux de Calendar.tsx:53-60, 71-73, 102-109, 120-122, 146-153, 164-166). Garder au plus un console.warn unique hors boucle.
|
||||||
|
|
||||||
|
### CAL-03
|
||||||
|
|
||||||
|
**Filtrage O(42 × éléments) à chaque rendu, sans mémoïsation** — perf-client, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/CalendarGrid.tsx:579`
|
||||||
|
- **Constat :** CalendarGrid.tsx:575-580 : dans `paddedDays.map`, `getItemsForDate(date)` parcourt et parse (safeDate) toutes les commandes et toutes les livraisons, et `getPublicitiesForDate(date)` parcourt toutes les publicités de l'année. Aucun useMemo. CalendarGrid n'est pas memo, et l'état des modales est dans Calendar.tsx (26-30), donc l'ouverture de 'Création rapide' ou d'une fiche recalcule toute la grille. Même chose sur mobile : CalendarPage.tsx:139-145 et 224.
|
||||||
|
- **Impact :** Avec quelques centaines d'éléments par mois, plusieurs dizaines de milliers d'opérations par rendu. L'interface rame à l'ouverture des modales et à la navigation, surtout sur les postes de magasin peu puissants.
|
||||||
|
- **Recommandation :** Construire une seule fois, avec useMemo, une Map<'yyyy-MM-dd', {orders, deliveries, pubs}> (une passe sur chaque liste). Envelopper CalendarGrid dans React.memo et passer des handlers stables (useCallback).
|
||||||
|
|
||||||
|
### CAL-04
|
||||||
|
|
||||||
|
**Spinner plein écran à chaque changement de mois, et la grille attend les publicités** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:204`
|
||||||
|
- **Constat :** Calendar.tsx:204 `const isLoading = loadingOrders || loadingDeliveries || loadingPublicities;` puis 282-285 : un spinner remplace toute la grille. Chaque mois a une nouvelle clé (38-41, 87-90), sans placeholderData et sans préchargement des mois voisins.
|
||||||
|
- **Impact :** La grille disparaît puis réapparaît à chaque clic sur les flèches. L'affichage dépend de la requête la plus lente (publicités, sans rapport avec la logistique), ce qui donne une impression de lenteur.
|
||||||
|
- **Recommandation :** Ajouter `placeholderData: keepPreviousData` sur les 3 requêtes et afficher la grille avec un indicateur discret (barre fine ou opacité). Ne pas bloquer sur loadingPublicities. Précharger mois-1 et mois+1 avec queryClient.prefetchQuery. Pour le premier chargement, utiliser un squelette de grille plutôt qu'un spinner.
|
||||||
|
|
||||||
|
### CAL-06
|
||||||
|
|
||||||
|
**La légende ne correspond pas aux couleurs utilisées dans la grille** — coherence-design, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:224`
|
||||||
|
- **Constat :** Calendar.tsx:224-237 : la légende utilise `bg-primary` (bleu vif), `bg-secondary` (vert foncé hsl(120,61%,34%)) et `bg-delivered` (gris). La grille utilise CalendarGrid.tsx:57-61 `bg-gray-400` (livré), `bg-yellow-300` (planifié, absent de la légende), `bg-blue-300` (commande), et 102-105 `bg-green-200` (livraison). Les publicités violettes (658) ne figurent pas dans la légende.
|
||||||
|
- **Impact :** L'utilisateur ne peut pas décoder le calendrier : il ne sait pas ce que signifient le jaune ou le violet.
|
||||||
|
- **Recommandation :** Générer la légende à partir des mêmes constantes que la grille et ajouter 'Commande planifiée' (jaune) et 'Publicité' (violet). Voir aussi ORD-12 pour un composant de statut unique.
|
||||||
|
|
||||||
|
### CAL-07
|
||||||
|
|
||||||
|
**Éléments du calendrier illisibles : texte de 11 px, icônes de 8 px, abréviations P/C, statut codé par des points** — lisibilite, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/CalendarGrid.tsx:75`
|
||||||
|
- **Constat :** CalendarGrid.tsx:75 `style={{fontSize: '11px', lineHeight: '1.2'}}`. 81-82 `w-4 h-4 ... <Link className="w-2 h-2"`. 50 `${quantity}${unit === 'palettes' ? 'P' : 'C'}`. 85-87 et 148-150 : statut indiqué par un point de 2×2 px avec un simple title. Le mois est affiché deux fois (Calendar.tsx:217-219 et 254-256).
|
||||||
|
- **Impact :** Des employés non techniciens, sur des écrans de magasin, ne lisent pas les noms de fournisseurs et ne comprennent pas 'P', 'C' ni les pastilles.
|
||||||
|
- **Recommandation :** Même reco, en corrigeant la preuve : le point de statut fait 8×8 px (w-2 h-2), les icônes de liaison et de commentaire 8 px, et l'icône livré 12 px.
|
||||||
|
|
||||||
|
### CAL-08
|
||||||
|
|
||||||
|
**Un clic n'importe où dans une case ouvre 'Création rapide', même sans droits, et le '+' n'est visible qu'au survol** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:181`
|
||||||
|
- **Constat :** Calendar.tsx:181-184 `handleDateClick` ouvre QuickCreate sans vérifier de permission, alors que le bouton 'Nouveau' (267) est conditionné. CalendarGrid.tsx:594-603 : onClick sur toute la case. QuickCreateMenu.tsx:67-71 affiche alors 'Vous n'avez pas les permissions…'. CalendarGrid.tsx:644 et 675 `opacity-0 group-hover:opacity-100` pour le bouton '+'.
|
||||||
|
- **Impact :** Des modales s'ouvrent par erreur au moindre clic, avec un message d'erreur pour les employés. Sur tablette tactile, le bouton '+' est introuvable.
|
||||||
|
- **Recommandation :** Ne pas rendre la case cliquable pour créer : utiliser un bouton '+' toujours visible, et uniquement si l'utilisateur a orders:create ou deliveries:create. Un clic sur la case peut ouvrir une vue 'jour'.
|
||||||
|
|
||||||
|
### CAL-10
|
||||||
|
|
||||||
|
**Le panneau de statistiques en position fixe masque le bas du calendrier** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/StatsPanel.tsx:54`
|
||||||
|
- **Constat :** StatsPanel.tsx:54 `<Card className="fixed bottom-6 right-6 min-w-80 shadow-lg border-gray-200">` (et 37 pour le squelette). Il est rendu par Calendar.tsx:335. Il n'est ni repliable ni déplaçable.
|
||||||
|
- **Impact :** Les cases samedi et dimanche des dernières semaines sont recouvertes : les livraisons qui y figurent ne sont pas visibles.
|
||||||
|
- **Recommandation :** Intégrer les 4 indicateurs dans un bandeau de KPI sous l'en-tête, ou dans un panneau latéral repliable.
|
||||||
|
|
||||||
|
### CAL-11
|
||||||
|
|
||||||
|
**Panneau 'Statistiques du mois' : chiffres Palettes, Colis et 'en attente' faux** — bug, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:1297`
|
||||||
|
- **Constat :** storage.ts:1297-1313 : `totalPalettes: SUM(CAST(quantity as INTEGER))` sans filtre sur unit, donc palettes et colis sont additionnés. `totalPackages: COUNT(*)`, affiché 'Colis' (StatsPanel.tsx:85-87), est en fait un nombre de livraisons. `.innerJoin(orders, …)` exclut les livraisons sans commande. pendingResult (1281-1290) n'est pas filtré par mois, mais il est affiché sous 'Statistiques du mois' (StatsPanel.tsx:101-105). Les lignes 1230-1240 renvoient des statistiques fictives (12, 8, 45…) si NODE_ENV==='development'.
|
||||||
|
- **Impact :** Les managers et directeurs lisent des volumes de palettes et de colis erronés. Ils peuvent prendre de mauvaises décisions et perdre confiance dans l'outil.
|
||||||
|
- **Recommandation :** Calculer `SUM(quantity) FILTER (WHERE unit='palettes')` et `SUM(quantity) FILTER (WHERE unit='colis')` en leftJoin. Filtrer 'en attente' sur le mois, ou le renommer 'Commandes en attente (toutes dates)'. Supprimer le retour de données fictives.
|
||||||
|
|
||||||
|
### CAL-15
|
||||||
|
|
||||||
|
**getPublicities ignore le filtre magasin, charge des groupes complets et regroupe en O(P×N)** — perf-serveur, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:1502`
|
||||||
|
- **Constat :** storage.ts:1502 `async getPublicities(year?: number, groupIds?: number[])`. groupIds n'est jamais utilisé dans la requête (seul `eq(publicities.year, year)`). 1532-1541 : participations avec `group: groups` (logo). 1551-1553 : `participations.filter(p => p.publicityId === publicity.id)` pour chaque publicité. Côté client, Calendar.tsx:137-141 charge l'année entière, puis CalendarGrid.tsx:399-455 filtre en JS, pour chacun des 42 jours.
|
||||||
|
- **Impact :** Les publicités de toute l'année et de tous les magasins sont transférées et traitées à chaque affichage du calendrier. Le calcul est inutile côté serveur comme côté navigateur.
|
||||||
|
- **Recommandation :** Filtrer en SQL : `publicities.end_date >= :from AND start_date <= :to` et `EXISTS (participation WHERE group_id IN groupIds)`. Projeter group {id, name, color}. Regrouper les participations avec une Map.
|
||||||
|
|
||||||
|
### CAL-16
|
||||||
|
|
||||||
|
**fetch() manuels dupliqués : aucune gestion du 401 et aucun état d'erreur affiché** — dette-code, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:62`
|
||||||
|
- **Constat :** Calendar.tsx:62-68 `const response = await fetch(url, ...); if (!response.ok) { throw new Error('Failed to fetch orders'); }`. Le message ne contient pas '401', donc il n'y a pas de redirection et queryClient.ts:79-88 relance 2 fois. Aucun rendu d'erreur : `data: orders = []`, ce qui donne un calendrier vide. Même schéma dans StatsPanel.tsx:22-31, Orders.tsx:78-87 et Deliveries.tsx:84-95. Le mobile (OrdersPage.tsx:51, DeliveriesPage.tsx:53, CalendarPage.tsx:67-69) fait `return []` sur erreur.
|
||||||
|
- **Impact :** Quand le serveur ou la session échoue, l'utilisateur voit 'Aucune commande trouvée' ou un calendrier vide, sans message ni bouton Réessayer, avec 3 fois plus d'appels.
|
||||||
|
- **Recommandation :** Utiliser la queryFn par défaut (getQueryFn), en construisant l'URL depuis la clé, ou un helper `fetchJson(url)` commun qui lève 'status: texte'. Ajouter un composant <ErrorState onRetry={refetch}/> sur chaque page.
|
||||||
|
|
||||||
|
### LIV-01
|
||||||
|
|
||||||
|
**Le filtre 'En attente' des livraisons ne correspond à aucun statut existant** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Deliveries.tsx:325`
|
||||||
|
- **Constat :** Deliveries.tsx:325 `<SelectItem value="pending">En attente</SelectItem>`. Le schéma deliveries.status vaut 'planned, delivered' (shared/schema.ts:146) et createDelivery force 'planned' (storage.ts:1099-1102). Le badge 'pending' (199) n'est donc jamais utilisé.
|
||||||
|
- **Impact :** Le filtre renvoie toujours 'Aucune livraison trouvée'. L'utilisateur conclut à tort qu'il n'a rien à recevoir.
|
||||||
|
- **Recommandation :** Retirer l'option 'pending' du Select de Deliveries.tsx. Renommer 'planned' en 'À recevoir' est un choix de libellé à valider séparément.
|
||||||
|
|
||||||
|
### LIV-02
|
||||||
|
|
||||||
|
**'Contrôle effectué' validé en un clic, sans confirmation** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Deliveries.tsx:484`
|
||||||
|
- **Constat :** Deliveries.tsx:480-490 `onClick={() => handleMarkControlValidated(delivery.id)}` lance directement PUT /api/deliveries/:id/control. L'icône CheckCircle se trouve à côté de l'icône Check de validation de livraison (469-478). Il n'y a pas d'annulation possible.
|
||||||
|
- **Impact :** Un clic involontaire marque le contrôle qualité comme fait, de façon irréversible depuis l'interface. Cela pose un risque de traçabilité.
|
||||||
|
- **Recommandation :** Utiliser un bouton texte 'Contrôle fait' avec une confirmation courte (ou une annulation via toast avec 'Annuler'), placé dans la colonne Statut à côté du badge 'Contrôle à faire'.
|
||||||
|
|
||||||
|
### MCAL-01
|
||||||
|
|
||||||
|
**Statuts affichés bruts en anglais et titres techniques 'Cmd #' / 'Liv #'** — lisibilite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/CalendarPage.tsx:293`
|
||||||
|
- **Constat :** CalendarPage.tsx:288-294 `<Badge ...>{event.status}</Badge>` affiche 'pending', 'planned' ou 'delivered'. Lignes 105 et 113 : `title: `Cmd #${order.orderNumber || order.id}`` et `Liv #…`.
|
||||||
|
- **Impact :** Des libellés anglais et des abréviations sont présentés à des utilisateurs francophones non techniciens.
|
||||||
|
- **Recommandation :** Partie automatisable : un helper statusLabel(status) qui renvoie pending→'En attente', planned→'Planifiée', delivered→'Livrée', utilisé dans le Badge, et suppression des références à orderNumber et deliveryNumber. La restructuration titre/sous-titre (fournisseur en titre) est à valider côté produit.
|
||||||
|
|
||||||
|
### MOB-03
|
||||||
|
|
||||||
|
**Les erreurs serveur sont présentées comme 'Aucune commande trouvée'** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/mobile/OrdersPage.tsx:51`
|
||||||
|
- **Constat :** OrdersPage.tsx:51 `if (!response.ok) return [];`. DeliveriesPage.tsx:53 idem. CalendarPage.tsx:67-69 et 92-94 idem. Le rendu affiche ensuite l'état vide (OrdersPage.tsx:140-144).
|
||||||
|
- **Impact :** Lors d'une panne ou d'une session expirée, l'employé pense qu'il n'y a aucune livraison prévue.
|
||||||
|
- **Recommandation :** Lever une erreur, afficher 'Impossible de charger les données' avec un bouton Réessayer, et rediriger vers /auth en cas de 401.
|
||||||
|
|
||||||
|
### MOD-02
|
||||||
|
|
||||||
|
**Gestion de session expirée inopérante : regex jamais vraie et redirection vers une route inexistante** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/lib/authUtils.ts:2`
|
||||||
|
- **Constat :** authUtils.ts:2 `return /^401: .*Unauthorized/.test(error.message);` alors que le serveur répond `{ message: "Authentification requise" }` (localAuth.ts:250-254), d'où un message '401: {"message":"Authentification requise"}' qui ne correspond jamais. Par ailleurs `window.location.href = "/api/login"` (CreateOrderModal.tsx:139, EditOrderModal.tsx:81, OrderDetailModal.tsx:58/91/137, CreateDeliveryModal.tsx:170, EditDeliveryModal.tsx:101, ValidateDeliveryModal.tsx:82, Orders.tsx:156, Deliveries.tsx:162) pointe vers une route qui n'existe qu'en POST (localAuth.production.ts:203), et le fallback SPA exclut /api/ (index.production.ts:158-161).
|
||||||
|
- **Impact :** Quand la session expire, l'utilisateur voit 'Impossible de créer la commande' sans comprendre et peut ressaisir plusieurs fois. Si la branche 401 était atteinte, il tomberait sur la page brute 'Cannot GET /api/login'.
|
||||||
|
- **Recommandation :** Remplacer la regex par `/^401\b/` ET remplacer les 25 occurrences de "/api/login" par "/auth" (y compris Users.tsx, Suppliers.tsx, Groups.tsx), dans le même changement. Centraliser via MutationCache ensuite.
|
||||||
|
|
||||||
|
### MOD-03
|
||||||
|
|
||||||
|
**Un admin en 'Tous les magasins' ne peut pas choisir le magasin : le premier est imposé** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/CreateOrderModal.tsx:84`
|
||||||
|
- **Constat :** CreateOrderModal.tsx:79-85 `defaultGroupId = groups[0].id.toString();` quand aucun magasin n'est sélectionné, puis 202-221 : affichage en lecture seule (aucun Select). CreateDeliveryModal.tsx:99-105 et 265-284 : même chose.
|
||||||
|
- **Impact :** La commande ou la livraison est créée dans le premier magasin alphabétique, sans possibilité de le changer depuis le formulaire. Cela produit des erreurs de saisie difficiles à repérer.
|
||||||
|
- **Recommandation :** Si l'utilisateur a plusieurs magasins et qu'aucun n'est sélectionné dans l'en-tête, afficher un Select 'Magasin *' obligatoire, sans valeur par défaut. Sinon, afficher le magasin en lecture seule.
|
||||||
|
|
||||||
|
### MOD-04
|
||||||
|
|
||||||
|
**Choix du fournisseur dans une longue liste sans recherche** — ux-simplicite, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/CreateOrderModal.tsx:188`
|
||||||
|
- **Constat :** CreateOrderModal.tsx:188-199, CreateDeliveryModal.tsx:227-238, EditOrderModal.tsx:132-143 et EditDeliveryModal.tsx:155-166 utilisent un `<Select>` Radix contenant tous les fournisseurs, issus de GET /api/suppliers (routes.ts:1150 `storage.getSuppliers()`).
|
||||||
|
- **Impact :** Avec des dizaines de fournisseurs, il faut faire défiler une liste longue : la saisie est lente et les erreurs de sélection fréquentes.
|
||||||
|
- **Recommandation :** Utiliser un Combobox (shadcn Command) avec saisie et filtrage, en affichant d'abord les fournisseurs récents ou fréquents du magasin.
|
||||||
|
|
||||||
|
### ORD-03
|
||||||
|
|
||||||
|
**Log par ligne à chaque frappe et filtrage sans mémoïsation** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:169`
|
||||||
|
- **Constat :** Orders.tsx:169 `console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter });` dans le `.filter`. En production l'appel est retiré, mais l'objet littéral reste alloué pour chaque commande à chaque rendu. Lignes 93-99 : log à chaque rendu avec `orders?.slice(0, 2)`. 84-85 et 109-130 : autres logs. `searchTerm.toLowerCase()` est recalculé 3 fois par ligne (170-172) et il n'y a pas de useMemo. Deliveries.tsx:86, 92-93, 101-107 et 175 : même chose.
|
||||||
|
- **Impact :** La saisie dans la recherche devient saccadée quand l'historique est grand, puisque tout l'historique est rechargé côté client (ORD-02).
|
||||||
|
- **Recommandation :** Supprimer tous les logs. Faire `const q = useDeferredValue(searchTerm.toLowerCase())` puis `const filtered = useMemo(() => …, [orders, q, statusFilter])`.
|
||||||
|
|
||||||
|
### ORD-05
|
||||||
|
|
||||||
|
**Invalidations qui ratent leur cible : clés de cache hétérogènes pour les mêmes données** — bug, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:76`
|
||||||
|
- **Constat :** Orders.tsx:76-77 `queryKey: [ordersUrl, selectedStoreId]` donne ['/api/orders?storeId=3', 3]. Or EditOrderModal.tsx:69, OrderDetailModal.tsx:47/122, ValidateDeliveryModal.tsx:65 et EditDeliveryModal.tsx:89 font `invalidateQueries({ queryKey: ['/api/orders'] })`, qui ne correspond pas à '/api/orders?storeId=3'. StatsPanel.tsx:21 `queryKey: [statsUrl, selectedStoreId]` ('/api/stats/monthly?year=…') n'est jamais touché par `['/api/stats/monthly']` (EditOrderModal:70, EditDeliveryModal:90, ValidateDeliveryModal:66, Deliveries.tsx:152). Deliveries.tsx:126/151 invalide `[deliveriesUrl]`, qui n'existe pas. Deliveries.tsx:83 met user?.role dans la clé. Les clés mobiles (CalendarPage.tsx:53 'yyyy-MM', OrdersPage.tsx:43) diffèrent du desktop. La suppression d'une livraison (Deliveries.tsx:150-152, OrderDetailModal.tsx:123-124) n'invalide pas /api/orders, alors que chaque commande embarque `deliveries`.
|
||||||
|
- **Impact :** Après une suppression ou une validation depuis la fiche, la liste Commandes reste inchangée quand un magasin est sélectionné. Les statistiques du mois ne se mettent jamais à jour après modification, validation ou suppression. L'icône 'liée à des livraisons' du calendrier reste affichée après suppression de la livraison. L'utilisateur croit que l'action n'a pas marché.
|
||||||
|
- **Recommandation :** Correction minimale et sûre, en gardant les queryFn personnalisées : Orders.tsx clé ['/api/orders', 'list', selectedStoreId]. StatsPanel clé ['/api/stats/monthly', year, month, selectedStoreId]. Supprimer les invalidations [deliveriesUrl]. Ajouter l'invalidation de ['/api/orders'] et ['/api/stats/monthly'] dans les onSuccess de suppression ou de modification de livraison (Deliveries.tsx, OrderDetailModal.tsx). La fabrique lib/queryKeys.ts peut venir plus tard.
|
||||||
|
|
||||||
|
### ORD-06
|
||||||
|
|
||||||
|
**La suppression recharge toutes les variantes en cache, y compris les inactives (invalidate puis refetch)** — perf-client, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:141`
|
||||||
|
- **Constat :** Orders.tsx:133-146 : `queryClient.invalidateQueries({ predicate: … '/api/orders' || '/api/deliveries' })` suivi de `queryClient.refetchQueries({ predicate: … })` sans `type: 'active'`. Tous les mois du calendrier visités et l'historique complet des livraisons sont donc rechargés en même temps. Lignes 125-127 : `localStorage.setItem('selectedStoreId', …)` est un contournement sans effet ici.
|
||||||
|
- **Impact :** Une suppression déclenche N gros appels simultanés. Le serveur et le réseau sont saturés juste après l'action.
|
||||||
|
- **Recommandation :** Garder uniquement invalidateQueries (refetchType 'active' par défaut), ou retirer la ligne avec une mise à jour optimiste (setQueryData). Supprimer le contournement localStorage.
|
||||||
|
|
||||||
|
### ORD-10
|
||||||
|
|
||||||
|
**Actions en icônes seules, sans libellé ni info-bulle, et deux coches ambiguës** — accessibilite, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Deliveries.tsx:453`
|
||||||
|
- **Constat :** Orders.tsx:382-406 : boutons Eye, Edit et Trash2 sans title ni aria-label. Deliveries.tsx:453-500 : jusqu'à 5 icônes (Eye, Edit, Check 'valider livraison', CheckCircle orange 'contrôle', Trash2). Seul le bouton contrôle a un title (487).
|
||||||
|
- **Impact :** Un utilisateur non technicien ne sait pas quelle coche valide la réception ou le contrôle. Les lecteurs d'écran annoncent 'bouton' sans nom.
|
||||||
|
- **Recommandation :** Partie automatisable : ajouter aria-label et title ('Voir', 'Modifier', 'Valider la réception', 'Marquer le contrôle comme effectué', 'Supprimer') à chaque bouton icône d'Orders.tsx et Deliveries.tsx. Le reste (bouton principal texte, menu) est à valider côté produit.
|
||||||
|
|
||||||
|
### ORD-11
|
||||||
|
|
||||||
|
**Tableaux tronqués sur écrans étroits : pas de défilement horizontal** — ux-simplicite, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:316`
|
||||||
|
- **Constat :** Orders.tsx:314-316 : wrapper `overflow-hidden`, puis `<table className="w-full min-w-[700px]">`, ce qui coupe la colonne Actions. Deliveries.tsx:362-364 : 8 colonnes dans `overflow-hidden`. L'en-tête (281) n'a pas de variantes responsive, contrairement à Orders (234).
|
||||||
|
- **Impact :** Sur tablette ou petit portable, les boutons Valider et Supprimer sont invisibles.
|
||||||
|
- **Recommandation :** Utiliser `overflow-x-auto` sur le wrapper et masquer 'Créé par' et 'Magasin' sous lg (`hidden lg:table-cell`). En dessous de md, afficher des cartes.
|
||||||
|
|
||||||
|
### ORD-12
|
||||||
|
|
||||||
|
**Couleurs et libellés de statut différents selon les pages, et couleurs des boutons principaux incohérentes** — coherence-design, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:190`
|
||||||
|
- **Constat :** Orders.tsx:190-201, Deliveries.tsx:196-207 et OrderDetailModal.tsx:170-181 : planned en bleu, delivered en vert, pending en gris. CalendarGrid.tsx:54-63 : planned en jaune, delivered en gris, pending en bleu. Mobile OrdersPage.tsx:75-85 : pending en jaune, 'Livrée'. Boutons 'Nouveau' : orange bg-accent (Calendar.tsx:270), bleu (Orders.tsx:247), vert (Deliveries.tsx:294). En-têtes de tableau mixtes dans Deliveries.tsx:367-391 (text-xs font-medium gray-500 contre text-sm font-black gray-800). Padding doublé (278 et 361).
|
||||||
|
- **Impact :** Le même statut change de couleur d'un écran à l'autre, ce qui empêche l'utilisateur de se fier aux repères visuels.
|
||||||
|
- **Recommandation :** Créer un composant unique `<StatusBadge kind="order|delivery" status>` avec des tokens (lib/status.ts) utilisé partout (desktop, mobile, calendrier, légende). Utiliser une seule couleur d'action principale. Uniformiser les en-têtes de tableau.
|
||||||
|
|
||||||
|
### ORD-13
|
||||||
|
|
||||||
|
**La suppression d'une commande liée à des livraisons échoue avec un message générique** — ux-simplicite, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:880`
|
||||||
|
- **Constat :** storage.ts:880-882 `await db.delete(orders).where(eq(orders.id, id));` ne traite pas les livraisons liées. init.sql:450-451 : `deliveries_order_id_fkey ... ON DELETE NO ACTION`. Le serveur renvoie donc 500 'Failed to delete order' (routes.ts:1683-1686), affiché 'Impossible de supprimer la commande' (Orders.tsx:160-164). ConfirmDeleteModal ne mentionne pas les livraisons liées.
|
||||||
|
- **Impact :** L'utilisateur ne comprend pas pourquoi la suppression échoue et retente, ou appelle le support.
|
||||||
|
- **Recommandation :** Côté serveur, compter les livraisons liées et renvoyer 409 avec 'Cette commande a N livraison(s) liée(s). Supprimez-les ou détachez-les d'abord.' Afficher ce message dans le toast et indiquer N dans la confirmation.
|
||||||
|
|
||||||
|
### PERM-01
|
||||||
|
|
||||||
|
**Deux matrices de permissions divergentes entre client et serveur** — dette-code, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/lib/permissions.ts:1`
|
||||||
|
- **Constat :** client/src/lib/permissions.ts (utilisée par Calendar.tsx:17, OrderDetailModal.tsx:15 et QuickCreateMenu.tsx:5) : orders.employee ['view'], deliveries.employee ['view'], pas de 'validate'. shared/permissions.ts (utilisée par Orders.tsx:13, Deliveries.tsx:13 et le serveur) : orders.employee [], deliveries avec 'validate'. D'où les rôles codés en dur dans OrderDetailModal.tsx:167 et la page desktop Commandes qui bloque l'employé (Orders.tsx:41-59) alors que le mobile ne le fait pas.
|
||||||
|
- **Impact :** Les boutons affichés ne correspondent pas aux droits réels : des actions refusées par le serveur sont proposées, d'autres sont masquées à tort.
|
||||||
|
- **Recommandation :** Supprimer client/src/lib/permissions.ts, importer @shared/permissions partout (desktop et mobile) et centraliser les gardes de page.
|
||||||
|
|
||||||
|
### SRV-02
|
||||||
|
|
||||||
|
**Imbrications redondantes : livraisons complètes dans chaque commande et commande complète dans chaque livraison** — perf-api, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:522`
|
||||||
|
- **Constat :** storage.ts:522-578 (loadDeliveriesByOrderIds) : chaque commande embarque ses livraisons avec 24 colonnes plus supplier, group et creator. storage.ts:584-617 (loadOrdersByIds) : chaque livraison embarque sa commande avec supplier, group et creator (email compris). Le client n'utilise que `deliveries.length`, le statut et les dates (CalendarGrid.tsx:80, OrderDetailModal.tsx:311-328) et `order.id`, plannedDate et status (Deliveries.tsx:436-439, OrderDetailModal.tsx:343-349).
|
||||||
|
- **Impact :** La charge utile des listes double ou triple sans bénéfice. La sérialisation et le parsing JSON sont plus lents.
|
||||||
|
- **Recommandation :** Pour les listes, renvoyer des sous-objets légers : deliveries {id, status, scheduledDate, deliveredDate, quantity, unit} et order {id, plannedDate, status}. Réserver le détail complet à GET /api/orders/:id et /api/deliveries/:id.
|
||||||
|
|
||||||
|
### SRV-04
|
||||||
|
|
||||||
|
**Utilisateur et magasins rechargés deux fois par requête API** — perf-serveur, sévérité moyenne, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `server/localAuth.production.ts:195`
|
||||||
|
- **Constat :** localAuth.production.ts:193-196 : `deserializeUser` appelle déjà `storage.getUserWithGroups(id)` (2 requêtes : users, puis user_groups JOIN groups, storage.ts:371-407). Ensuite, chaque handler refait `storage.getUserWithGroups(req.user.claims ? … : req.user.id)` (routes.ts:1345, 1754, 4157, 4597… 91 occurrences).
|
||||||
|
- **Impact :** 4 requêtes SQL d'authentification au lieu de 2 sur chaque appel. Le calendrier en fait 4 en parallèle (orders, deliveries, ad-campaigns, stats), soit 16 requêtes d'authentification par changement de mois.
|
||||||
|
- **Recommandation :** Créer `async function getRequestUser(req) { return req.user?.userGroups ? req.user : storage.getUserWithGroups(req.user?.claims ? req.user.claims.sub : req.user.id); }` et remplacer les 91 appels par ce helper. Le repli couvre le cas où req.user vient d'un login sans groupes, ou d'un ancien format avec claims.
|
||||||
|
|
||||||
|
### SRV-05
|
||||||
|
|
||||||
|
**Toutes les réponses JSON sont clonées récursivement pour retirer smtpPassword** — perf-serveur, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:168`
|
||||||
|
- **Constat :** routes.ts:165-170 `res.json = (body: any) => originalJson(stripSmtpPassword(body));`. sanitize.ts:13-40 parcourt et recopie chaque objet (Object.entries, WeakSet, profondeur jusqu'à 8) de chaque réponse /api, y compris les listes de milliers de commandes avec leurs imbrications.
|
||||||
|
- **Impact :** Une copie complète supplémentaire, en CPU et en mémoire, avant JSON.stringify, sur les plus grosses réponses. La latence des listes augmente.
|
||||||
|
- **Recommandation :** Ne jamais sélectionner smtpPassword dans les requêtes (projection, voir SRV-01) et limiter le nettoyage aux routes /api/groups* et /api/users*. Ou appliquer stripSmtpPassword seulement si le body contient un group (vérification à plat).
|
||||||
|
|
||||||
|
### SRV-07
|
||||||
|
|
||||||
|
**Manager ou directeur multi-magasins sans sélection : seul le premier magasin est renvoyé, en silence** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:1419`
|
||||||
|
- **Constat :** routes.ts:1401-1428 `groupIds = [userGroupIds[0]]; // Use first assigned store automatically` (commandes), et 1810-1837 pour les livraisons. À l'inverse, /api/stats/monthly (4173-4180) agrège tous les magasins de l'utilisateur.
|
||||||
|
- **Impact :** La liste et les statistiques ne portent pas sur le même périmètre, sans que l'écran l'indique. Les données d'un second magasin semblent manquantes.
|
||||||
|
- **Recommandation :** Choisir une règle unique : soit tous les magasins de l'utilisateur partout, soit une sélection obligatoire côté interface (Layout.tsx:195 affiche déjà un avertissement), en affichant clairement le magasin concerné.
|
||||||
|
|
||||||
|
### SRV-08
|
||||||
|
|
||||||
|
**Contrôles d'accès par magasin incomplets (validation, contrôle, lecture, statistiques)** — bug, sévérité moyenne, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:2624`
|
||||||
|
- **Constat :** routes.ts:2624 `if (user.role === 'manager') {` : un directeur peut valider une livraison de n'importe quel magasin via son id. PUT /api/deliveries/:id/control (2678-2705) ne vérifie pas l'appartenance au magasin. GET /api/orders (1343) ne vérifie pas hasPermission('orders','view'), alors que shared/permissions.ts:41 donne `employee: []`. Un employé qui passe un storeId reçoit donc les commandes (et la page mobile OrdersPage ne le bloque pas). /api/stats/monthly : si userGroupIds est vide, `groupIds = []` (4179), et storage.ts:1262 (`groupIds.length > 0`) n'applique aucun filtre, ce qui renvoie les statistiques de tous les magasins.
|
||||||
|
- **Impact :** Des actions et des données d'autres magasins sont accessibles, et le comportement diffère entre desktop et mobile.
|
||||||
|
- **Recommandation :** Créer un helper `assertStoreAccess(user, groupId)` et l'appliquer à validate, control, PUT et DELETE. Vérifier hasPermission(module,'view') sur les GET de liste. Traiter groupIds vide comme 'aucun accès', avec une réponse vide.
|
||||||
|
|
||||||
|
### CAL-05
|
||||||
|
|
||||||
|
**Les jours des mois voisins sont affichés mais toujours vides** — bug, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Calendar.tsx:33`
|
||||||
|
- **Constat :** Calendar.tsx:33-34 et 38-46 : requête de startOfMonth à endOfMonth. CalendarGrid.tsx:473-489 : grille de 42 jours qui inclut la fin du mois précédent et le début du suivant. Sur mobile : CalendarPage.tsx:55-56 (mois) contre 125-127 (startOfWeek/endOfWeek).
|
||||||
|
- **Impact :** Une livraison prévue le 1er du mois suivant n'apparaît pas dans la dernière semaine affichée. L'utilisateur peut croire qu'il n'y a rien.
|
||||||
|
- **Recommandation :** Desktop : calculer la plage comme la grille, from = monthStart - startPadding jours (lundi), to = from + 41 jours. Exposer ces bornes depuis un helper commun à Calendar et CalendarGrid et les utiliser dans les clés et paramètres des requêtes orders et deliveries. Mobile : requêter de startOfWeek(monthStart,{locale: fr}) à endOfWeek(monthEnd,{locale: fr}).
|
||||||
|
|
||||||
|
### CAL-12
|
||||||
|
|
||||||
|
**Statistiques mensuelles : 4 requêtes séquentielles, sans cache** — perf-serveur, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/storage.ts:1270`
|
||||||
|
- **Constat :** storage.ts:1270 `const ordersResult = await db…`, 1276 `const deliveriesResult = await db…`, 1287 `const pendingResult = await db…`, 1297 `const deliveriesStatsResult = await db…` : quatre await successifs et indépendants. server/cache.ts n'est pas utilisé pour ces statistiques.
|
||||||
|
- **Impact :** La latence du panneau est la somme de 4 allers-retours SQL à chaque affichage ou changement de mois.
|
||||||
|
- **Recommandation :** Partie automatisable : exécuter les 4 requêtes avec `Promise.all`. Le cache de 60 s par (année, mois, magasin) est à faire séparément, avec une invalidation dans toutes les routes de mutation des commandes et livraisons.
|
||||||
|
|
||||||
|
### CAL-13
|
||||||
|
|
||||||
|
**La fenêtre '+X autres' affiche la date de création des commandes au lieu de la date prévue** — bug, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/CalendarGrid.tsx:324`
|
||||||
|
- **Constat :** CalendarGrid.tsx:323-328 `{item.scheduledDate ? ... : item.deliveredDate ? ... : new Date(item.createdAt).toLocaleDateString('fr-FR')}`. Les commandes n'ont pas de scheduledDate mais un plannedDate (schema.ts:125).
|
||||||
|
- **Impact :** Une date incohérente s'affiche dans la liste du jour.
|
||||||
|
- **Recommandation :** Utiliser `isOrder ? item.plannedDate : item.scheduledDate` via safeFormat.
|
||||||
|
|
||||||
|
### CAL-14
|
||||||
|
|
||||||
|
**Seulement 2 éléments par jour, et des modales imbriquées avec des contournements de z-index et d'état** — ux-simplicite, sévérité basse, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/CalendarGrid.tsx:177`
|
||||||
|
- **Constat :** CalendarGrid.tsx:177 `const MAX_VISIBLE_ITEMS = 2;` puis le bouton '+N autres', qui ouvre un Dialog (224, `z-[9999]`), lequel ouvre un CommentModal (15, `z-[99999]`), lequel peut ouvrir OrderDetailModal. Des drapeaux d'état servent à contourner les clics (47, 113-116, 176, 261-268) avec un setTimeout (163, 346).
|
||||||
|
- **Impact :** Les jours chargés demandent un clic supplémentaire. La superposition de modales désoriente. Le code est fragile (clics fantômes).
|
||||||
|
- **Recommandation :** Afficher plus d'éléments en mode compact, ou proposer une vue semaine. Ouvrir un panneau latéral 'Journée du JJ/MM' qui liste tout, avec le commentaire en ligne au lieu d'une modale de commentaire.
|
||||||
|
|
||||||
|
### CAL-17
|
||||||
|
|
||||||
|
**Double chargement au démarrage : les requêtes partent avant la sélection automatique du magasin** — perf-client, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/Layout.tsx:70`
|
||||||
|
- **Constat :** Layout.tsx:70-73 sélectionne automatiquement le magasin après le chargement de /api/groups, et 77 expose `storeInitialized`. Mais les requêtes de Calendar.tsx:37, Orders.tsx:76 et Deliveries.tsx:82 n'ont pas `enabled: storeInitialized`. Le premier appel part sans storeId (le serveur prend alors le 1er magasin, routes.ts:1419-1423), puis un second part avec storeId.
|
||||||
|
- **Impact :** Deux gros appels au lieu d'un à la première connexion d'un manager ou d'un directeur.
|
||||||
|
- **Recommandation :** Si l'on corrige : faire passer storeInitialized à true aussi quand /api/groups a répondu avec une liste vide (ou en erreur) dans Layout.tsx, puis ajouter `enabled: !!user && storeInitialized` aux requêtes de Calendar, Orders et Deliveries. Gain faible : à prioriser bas.
|
||||||
|
|
||||||
|
### MOD-05
|
||||||
|
|
||||||
|
**useEffect et logs exécutés à chaque rendu (dépendance recréée)** — perf-client, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/CreateOrderModal.tsx:49`
|
||||||
|
- **Constat :** CreateOrderModal.tsx:49-53 : `const groups = ... groupsData.filter(...)` crée un nouveau tableau à chaque rendu. Il figure dans les dépendances de useEffect (103), donc l'effet tourne à chaque rendu, avec un console.log et `groups.map` (57-64). CreateDeliveryModal.tsx:53-57 et 76-124 : même chose. Logs aux lignes 107-109, 118 et 151.
|
||||||
|
- **Impact :** Travail et allocations inutiles à chaque frappe dans le formulaire.
|
||||||
|
- **Recommandation :** Envelopper groups dans useMemo et supprimer les console.log.
|
||||||
|
|
||||||
|
### MOD-06
|
||||||
|
|
||||||
|
**Données de référence rechargées à chaque ouverture de modale** — perf-client, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/lib/queryClient.ts:77`
|
||||||
|
- **Constat :** queryClient.ts:75-77 `refetchOnMount: true, staleTime: 30 * 1000`. Les modales sont montées conditionnellement (Orders.tsx:433-439, Calendar.tsx:318-332). À chaque ouverture après 30 s, /api/suppliers, /api/groups et /api/orders (modales livraison) sont donc refetchés.
|
||||||
|
- **Impact :** Les formulaires s'ouvrent avec des listes qui se rechargent, et le serveur reçoit des appels redondants.
|
||||||
|
- **Recommandation :** Définir `queryClient.setQueryDefaults(['/api/suppliers'], { staleTime: 15*60*1000 })`, et la même chose pour ['/api/groups']. Invalider ces clés seulement après une mutation fournisseur ou magasin.
|
||||||
|
|
||||||
|
### MOD-07
|
||||||
|
|
||||||
|
**Mutation morte, confirmation de suppression dupliquée et rôles codés en dur** — dette-code, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/OrderDetailModal.tsx:70`
|
||||||
|
- **Constat :** OrderDetailModal.tsx:70-101 : `validateDeliveryMutation` n'est jamais appelée (la validation passe par ValidateDeliveryModal). 405-432 : overlay de confirmation fait maison au lieu de ConfirmDeleteModal. 167-168 `canValidate = (user?.role === 'admin' || user?.role === 'directeur' || user?.role === 'manager')` au lieu de hasPermission.
|
||||||
|
- **Impact :** Code inutile, et une confirmation visuellement différente du reste de l'application.
|
||||||
|
- **Recommandation :** Partie automatisable : supprimer validateDeliveryMutation et remplacer le test de rôle par `hasPermission(user?.role || '', 'deliveries', 'validate')` importé de @shared/permissions (en gardant `isDelivery && item.status !== 'delivered'`). Passer à ConfirmDeleteModal est optionnel et visuel.
|
||||||
|
|
||||||
|
### MOD-08
|
||||||
|
|
||||||
|
**Modale d'édition empilée sur la fiche, données périmées après modification, contenu limité à 384 px** — ux-simplicite, sévérité basse, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/OrderDetailModal.tsx:437`
|
||||||
|
- **Constat :** OrderDetailModal.tsx:437-451 : EditOrderModal et EditDeliveryModal s'ouvrent par-dessus la fiche, qui reste ouverte. La fiche affiche `item`, une copie figée (Calendar.tsx:188 `setSelectedItem({ ...item, type })`), et montre donc les anciennes valeurs après modification. 220 : `overflow-y-auto max-h-96` dans une modale de 90vh. 194 : en-tête dégradé bleu même pour une livraison.
|
||||||
|
- **Impact :** L'utilisateur croit que sa modification n'a pas été prise en compte. Il doit défiler dans une petite zone.
|
||||||
|
- **Recommandation :** Passer la fiche en mode édition dans la même modale. Lire l'élément depuis le cache par id (useQuery(['/api/orders', id]) ou select sur la liste). Retirer max-h-96 au profit du scroll de DialogContent. Utiliser des couleurs distinctes pour commande et livraison.
|
||||||
|
|
||||||
|
### MOD-09
|
||||||
|
|
||||||
|
**Pieds de modale et boutons d'action incohérents** — coherence-design, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/CreateOrderModal.tsx:245`
|
||||||
|
- **Constat :** CreateOrderModal.tsx:245 et CreateDeliveryModal.tsx:334 : `flex items-center space-x-3`, boutons alignés à gauche. EditOrderModal.tsx:189, EditDeliveryModal.tsx:257 et ValidateDeliveryModal.tsx:160 : `justify-end`. EditDeliveryModal.tsx:264 : bouton bleu `bg-primary` alors que la création de livraison est verte (CreateDeliveryModal.tsx:341). Icônes Check entourées d'une bordure grise (ValidateDeliveryModal.tsx:105/175, OrderDetailModal.tsx:387/397, Deliveries.tsx:477).
|
||||||
|
- **Impact :** L'utilisateur cherche le bouton de validation à un endroit différent selon la modale.
|
||||||
|
- **Recommandation :** Utiliser DialogFooter partout ('Annuler' en outline à gauche, action principale à droite), avec une couleur par type d'objet, et retirer les bordures d'icônes.
|
||||||
|
|
||||||
|
### MOD-10
|
||||||
|
|
||||||
|
**Le champ 'Commande liée' disparaît si la commande liée est déjà livrée** — ux-simplicite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/EditDeliveryModal.tsx:191`
|
||||||
|
- **Constat :** EditDeliveryModal.tsx:55-61 filtre `order.status !== 'delivered'`, puis 191 `{availableOrders.length > 0 && (...)}`. La commande actuellement liée, si elle est livrée, n'est pas dans la liste et le champ peut être masqué.
|
||||||
|
- **Impact :** Impossible de voir ou de détacher la commande liée lors de la correction d'une livraison.
|
||||||
|
- **Recommandation :** Toujours afficher le champ et y inclure la commande actuellement liée (delivery.orderId), quel que soit son statut.
|
||||||
|
|
||||||
|
### ORD-04
|
||||||
|
|
||||||
|
**Requête /api/groups inutilisée** — perf-client, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:101`
|
||||||
|
- **Constat :** Orders.tsx:101-105 `const { data: groupsData = [] } = useQuery({ queryKey: ['/api/groups'] }); const groups = …;` : `groups` n'est jamais référencé (grep). Deliveries.tsx:109-113 : même chose.
|
||||||
|
- **Impact :** Abonnement et refetch inutiles (la liste admin contient les logos).
|
||||||
|
- **Recommandation :** Supprimer ces deux requêtes. Si besoin, utiliser `stores` du StoreContext.
|
||||||
|
|
||||||
|
### ORD-07
|
||||||
|
|
||||||
|
**Hooks appelés après un return anticipé (règle des hooks violée)** — bug, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:41`
|
||||||
|
- **Constat :** Orders.tsx:41-59 `if (user?.role === 'employee') { return (...) }` est placé avant `useState` (61-69), `useQuery` (76, 101), `useMutation` (107) et `usePagination` (180). Deliveries.tsx:46-64, puis useState à partir de 66 : même chose.
|
||||||
|
- **Impact :** Si le rôle change pendant la session (rechargement de l'utilisateur), React plante avec 'Rendered more/fewer hooks than expected' et l'écran devient blanc.
|
||||||
|
- **Recommandation :** Extraire le contenu dans un composant OrdersContent et faire le contrôle de rôle dans un wrapper, ou via une garde de route commune.
|
||||||
|
|
||||||
|
### ORD-08
|
||||||
|
|
||||||
|
**'Groupe' et 'Magasin/Groupe' au lieu de 'Magasin'** — lisibilite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Orders.tsx:323`
|
||||||
|
- **Constat :** Orders.tsx:323 colonne 'Groupe' et 264 'Rechercher par fournisseur, groupe...'. Deliveries.tsx:371 et 310. CreateOrderModal.tsx:205, EditOrderModal.tsx:147, OrderDetailModal.tsx:261 et CreateDeliveryModal.tsx:268 : 'Magasin/Groupe'. La navigation dit 'Magasins' (Sidebar.tsx:347) et le sélecteur 'Magasin' (Layout.tsx:161).
|
||||||
|
- **Impact :** Le jargon technique hérité (table groups) est incohérent et ambigu pour l'utilisateur.
|
||||||
|
- **Recommandation :** Remplacer 'Groupe' et 'Magasin/Groupe' par 'Magasin' aux 10 emplacements, EditDeliveryModal.tsx:170 compris.
|
||||||
|
|
||||||
|
### ORD-09
|
||||||
|
|
||||||
|
**Identifiants techniques affichés sous des formes différentes selon l'écran** — lisibilite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/modals/OrderDetailModal.tsx:209`
|
||||||
|
- **Constat :** OrderDetailModal.tsx:209 `#{isOrder ? 'CMD' : 'LIV'}-{item.id}`. Orders.tsx:350 et Deliveries.tsx:404 `#{order.id}`. Deliveries.tsx:439 `#{delivery.order.id}`. CreateDeliveryModal.tsx:252 `#{order.id} - {supplier} - date`. EditDeliveryModal.tsx:202 `Commande #…`. Mobile : `Cmd #` et `Liv #` (CalendarPage.tsx:105/113).
|
||||||
|
- **Impact :** Une même commande apparaît sous 3 notations, ce qui complique la recherche et la communication entre collègues.
|
||||||
|
- **Recommandation :** Adopter un format unique (CMD-123 / LIV-456) via un helper, et l'utiliser aussi dans la recherche. Dans la colonne 'Commande liée', afficher plutôt la date et le fournisseur.
|
||||||
|
|
||||||
|
### ORD-14
|
||||||
|
|
||||||
|
**Retour à la page 1 après chaque suppression, et confirmation fermée avant la fin de l'action** — ux-simplicite, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/components/ui/pagination.tsx:157`
|
||||||
|
- **Constat :** pagination.tsx:157-159 `useEffect(() => { setCurrentPage(1); }, [data.length]);`. Orders.tsx:218-224 `deleteMutation.mutate(...); setShowDeleteModal(false);` : l'état isLoading passé à ConfirmDeleteModal (468) n'est jamais visible. Même chose dans Deliveries.tsx:264-270.
|
||||||
|
- **Impact :** Après une suppression en page 4, l'utilisateur est renvoyé en page 1 et perd sa place. Il n'a aucun retour visuel pendant la suppression.
|
||||||
|
- **Recommandation :** Ne réinitialiser la page que si les filtres changent (et borner à totalPages). Fermer la confirmation dans onSuccess.
|
||||||
|
|
||||||
|
### ORD-15
|
||||||
|
|
||||||
|
**Pas de tri, pas de filtre de période, recherche sans n° de BL** — ux-simplicite, sévérité basse, effort M
|
||||||
|
|
||||||
|
- **Fichier :** `client/src/pages/Deliveries.tsx:176`
|
||||||
|
- **Constat :** Deliveries.tsx:176-178 : la recherche porte sur supplier, group et notes, mais pas sur blNumber. Orders.tsx:170-172 : même chose. L'ordre est celui du serveur, `orderBy(desc(orders.createdAt))` (storage.ts:715) et desc(deliveries.createdAt) (931), et non la date prévue. Aucun en-tête de colonne n'est triable et il n'existe pas de filtre 'cette semaine'.
|
||||||
|
- **Impact :** Pour trouver les livraisons de demain ou un BL précis, l'utilisateur doit parcourir les pages.
|
||||||
|
- **Recommandation :** Trier par date prévue par défaut (les prochaines en premier). Ajouter des raccourcis 'Aujourd'hui', 'Cette semaine' et 'En retard'. Inclure blNumber dans la recherche, en SQL (voir ORD-02).
|
||||||
|
|
||||||
|
### SRV-06
|
||||||
|
|
||||||
|
**Logs console à chaque appel des chemins chauds** — perf-serveur, sévérité basse, effort S
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:1353`
|
||||||
|
- **Constat :** routes.ts:1353 `console.log('Orders API called with:', …)`, 1360-1430 (un log par branche de rôle), 1456 `console.log('Orders returned:', …)`, 1762-1869 (même chose pour les livraisons). storage.ts:719 `getOrders() récupéré…`, 934, 1252 et 1330 (stats), 1519-1522 (publicités, avec la désignation des 3 premières).
|
||||||
|
- **Impact :** Des E/S synchrones sur stdout à chaque requête et des logs de production illisibles.
|
||||||
|
- **Recommandation :** Utiliser un logger à niveaux (debug désactivé en production) et ne garder que les erreurs.
|
||||||
|
|
||||||
|
### SRV-09
|
||||||
|
|
||||||
|
**Le filtre withBL est appliqué en JavaScript après le chargement complet** — perf-serveur, sévérité basse, effort S — vérification : partiellement confirmé
|
||||||
|
|
||||||
|
- **Fichier :** `server/routes.ts:1864`
|
||||||
|
- **Constat :** routes.ts:1864-1866 `if (withBL === 'true') { deliveries = deliveries.filter((d: any) => d.blNumber && d.status === 'delivered'); }`, exécuté après getDeliveries ou getDeliveriesByDateRange complet.
|
||||||
|
- **Impact :** Toutes les livraisons sont lues et sérialisées pour n'en renvoyer qu'une partie.
|
||||||
|
- **Recommandation :** Ajouter à getDeliveries un filtre SQL optionnel (status = 'delivered', voire bl_number IS NOT NULL) exposé par un paramètre de /api/deliveries, et l'utiliser depuis BLReconciliation.tsx, après avoir vérifié que la page n'a pas besoin des livraisons sans BL. Supprimer ou réutiliser le paramètre mort withBL.
|
||||||