Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3be451a8c | ||
|
|
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,17 @@ POSTGRES_PORT=5434
|
||||
|
||||
# Note de sécurité:
|
||||
# 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).
|
||||
# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée.
|
||||
# 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
|
||||
server/public
|
||||
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 frontend first
|
||||
RUN npx vite build
|
||||
RUN NODE_ENV=production npx vite build
|
||||
|
||||
# Vérifier que les fichiers sont construits
|
||||
RUN echo "=== BUILD VERIFICATION ===" && \
|
||||
@@ -61,7 +61,9 @@ RUN npx esbuild server/index.production.ts \
|
||||
--external:express-rate-limit \
|
||||
--external:memoizee \
|
||||
--external:nanoid \
|
||||
--external:date-fns
|
||||
--external:date-fns \
|
||||
--external:nodemailer \
|
||||
--external:compression
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
@@ -98,6 +100,8 @@ USER nextjs
|
||||
# Expose port
|
||||
EXPOSE 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
|
||||
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>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||
@@ -15,7 +15,5 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<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>
|
||||
</html>
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Type pour les annonces avec relations
|
||||
type AnnouncementWithRelations = DashboardMessage & {
|
||||
export type AnnouncementWithRelations = DashboardMessage & {
|
||||
author: {
|
||||
id: string | number;
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
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
|
||||
const { data: announcements = [], isLoading } = useQuery<AnnouncementWithRelations[]>({
|
||||
queryKey: ['/api/announcements', 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;
|
||||
},
|
||||
const { data: announcements = [], isLoading } = useQuery({
|
||||
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
@@ -170,9 +170,7 @@ export default function AnnouncementCard() {
|
||||
: '/api/announcements';
|
||||
|
||||
const method = editingAnnouncement ? 'PUT' : 'POST';
|
||||
|
||||
console.log(`🔍 [ANNOUNCEMENTS] ${method} ${url}`, announcementData);
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -241,8 +239,6 @@ export default function AnnouncementCard() {
|
||||
|
||||
// Soumission du formulaire
|
||||
const onSubmit = (data: InsertAnnouncement) => {
|
||||
console.log('🔍 [ANNOUNCEMENTS] Form submitted:', data);
|
||||
|
||||
const announcementData = {
|
||||
...data,
|
||||
createdBy: user?.username || '',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, isToday } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
|
||||
import { safeDate } from "@/lib/dateUtils";
|
||||
import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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
|
||||
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,
|
||||
orders,
|
||||
deliveries,
|
||||
@@ -363,197 +365,158 @@ export default function CalendarGrid({
|
||||
onDateClick,
|
||||
onItemClick,
|
||||
}: CalendarGridProps) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🗓️ CalendarGrid rendered with:', {
|
||||
currentDate: currentDate?.toISOString(),
|
||||
ordersCount: orders?.length || 0,
|
||||
deliveriesCount: deliveries?.length || 0,
|
||||
publicitiesCount: publicities?.length || 0,
|
||||
selectedStoreId,
|
||||
userGroupsCount: userGroups?.length || 0
|
||||
// Jours affichés : 6 semaines complètes commençant un lundi
|
||||
const paddedDays = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// Get all days in the month
|
||||
const monthDays = eachDayOfInterval({
|
||||
start: monthStart,
|
||||
end: monthEnd,
|
||||
});
|
||||
}
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// Pad the calendar to start on Monday
|
||||
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
|
||||
if (!publicities || !Array.isArray(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 {
|
||||
return publicities.filter(pub => {
|
||||
let invalidCount = 0;
|
||||
const storePublicities = publicities.filter(pub => {
|
||||
// Vérifications de sécurité pour éviter les plantages
|
||||
if (!pub || typeof pub !== 'object') {
|
||||
console.warn('⚠️ Invalid publicity object:', pub);
|
||||
if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) {
|
||||
invalidCount++;
|
||||
return false;
|
||||
}
|
||||
// Check if the date is within the publicity period
|
||||
const pubStart = pub.startDate;
|
||||
const pubEnd = pub.endDate;
|
||||
|
||||
if (!pubStart || !pubEnd) {
|
||||
console.warn('⚠️ Publicity missing dates:', { pubNumber: pub.pubNumber, startDate: pubStart, endDate: pubEnd });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateStr < pubStart || dateStr > pubEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no store is selected and user has no assigned groups, show only publicities with participations
|
||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
if (hasParticipations && import.meta.env.DEV) {
|
||||
console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
||||
// If no store is selected and user has no assigned groups, show only publicities with participations
|
||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
}
|
||||
return hasParticipations;
|
||||
}
|
||||
|
||||
// If a specific store is selected, check if that store participates
|
||||
if (selectedStoreId) {
|
||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
||||
if (matches && import.meta.env.DEV) {
|
||||
console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId });
|
||||
// If a specific store is selected, check if that store participates
|
||||
if (selectedStoreId) {
|
||||
return pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// If no specific store selected but user has assigned stores,
|
||||
// show publicities where any of user's stores participate
|
||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
||||
if (matches) {
|
||||
console.log('👥 Publicity matches user groups:', { pubNumber: pub.pubNumber, userGroupIds });
|
||||
// If no specific store selected but user has assigned stores,
|
||||
// show publicities where any of user's stores participate
|
||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||
return pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Default case: only show publicities with participations
|
||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
if (hasParticipations) {
|
||||
console.log('📋 Publicity has participations (default case):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
||||
}
|
||||
return hasParticipations;
|
||||
// Default case: only show publicities with participations
|
||||
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
});
|
||||
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`⚠️ CalendarGrid: ${invalidCount} publicité(s) invalide(s) ou sans dates ignorée(s)`);
|
||||
}
|
||||
|
||||
if (storePublicities.length > 0) {
|
||||
for (const date of paddedDays) {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
// Check if the date is within the publicity period
|
||||
const dayPublicities = storePublicities.filter(pub => !(dateStr < pub.startDate || dateStr > pub.endDate));
|
||||
if (dayPublicities.length > 0) {
|
||||
byDay.set(dateStr, dayPublicities);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error filtering publicities:', error);
|
||||
return [];
|
||||
byDay.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// Get all days in the month
|
||||
const monthDays = eachDayOfInterval({
|
||||
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);
|
||||
}
|
||||
return byDay;
|
||||
}, [publicities, selectedStoreId, userGroups, paddedDays]);
|
||||
|
||||
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 (
|
||||
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
|
||||
{/* Calendar Header - Design moderne épuré */}
|
||||
@@ -576,8 +539,11 @@ export default function CalendarGrid({
|
||||
const isCurrentMonth = isSameMonth(date, currentDate);
|
||||
const isTodayDate = isToday(date);
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
const { orders: dayOrders, deliveries: dayDeliveries } = getItemsForDate(date);
|
||||
const dayPublicities = getPublicitiesForDate(date);
|
||||
const dayKey = format(date, 'yyyy-MM-dd');
|
||||
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 (
|
||||
<div
|
||||
@@ -694,3 +660,6 @@ export default function CalendarGrid({
|
||||
</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
|
||||
style={{
|
||||
backgroundColor: order.group.color + "20",
|
||||
color: order.group.color,
|
||||
color: order.group.color || undefined,
|
||||
border: `1px solid ${order.group.color}30`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -42,13 +42,14 @@ const customerOrderFormSchema = z.object({
|
||||
customerNotified: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
customerEmail: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>;
|
||||
|
||||
interface CustomerOrderFormProps {
|
||||
order?: CustomerOrderWithRelations;
|
||||
onSubmit: (data: CustomerOrderFormData) => void;
|
||||
onSubmit: (data: Record<string, any>) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -113,7 +114,7 @@ export function CustomerOrderForm({
|
||||
quantity: order?.quantity || 1,
|
||||
supplierId: order?.supplierId || 1,
|
||||
status: "En attente de Commande", // Statut fixe
|
||||
deposit: order?.deposit || 0,
|
||||
deposit: order?.deposit ? Number(order.deposit) : 0,
|
||||
isPromotionalPrice: order?.isPromotionalPrice || false,
|
||||
customerNotified: order?.customerNotified || false,
|
||||
groupId: order?.groupId || getUserAssignedGroupId() || 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -39,13 +40,9 @@ interface DlcAlertModalProps {
|
||||
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [, setLocation] = useLocation();
|
||||
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
|
||||
const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({
|
||||
queryKey: ["/api/dlc-products", "expires", selectedStoreId],
|
||||
@@ -138,8 +135,10 @@ export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: Dl
|
||||
markStockEpuiseMutation.mutate(productId);
|
||||
};
|
||||
|
||||
// Navigation interne : pas de rechargement complet de l'application
|
||||
const handleViewDlcModule = () => {
|
||||
window.location.href = '/dlc';
|
||||
setLocation('/dlc');
|
||||
onClose();
|
||||
};
|
||||
|
||||
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 { useLocation } from "wouter";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -9,6 +9,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||
import { StoreProvider } from "@/contexts/StoreContext";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import WeatherWidget from "./WeatherWidget";
|
||||
import DateWidget from "./DateWidget";
|
||||
import type { Group } from "@shared/schema";
|
||||
@@ -18,6 +19,23 @@ interface LayoutProps {
|
||||
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) {
|
||||
const { user } = useAuthUnified();
|
||||
const [location] = useLocation();
|
||||
@@ -45,11 +63,35 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const { data: stores = [] } = useQuery<Group[]>({
|
||||
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
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
|
||||
useEffect(() => {
|
||||
if (user && stores.length > 0) {
|
||||
@@ -82,8 +124,21 @@ export default function Layout({ children }: LayoutProps) {
|
||||
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 (
|
||||
<StoreProvider value={{ selectedStoreId, setSelectedStoreId, stores, sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen, storeInitialized }}>
|
||||
<StoreProvider value={storeContextValue}>
|
||||
<div className="layout-container flex bg-gray-50">
|
||||
{/* Mobile overlay for normal mode */}
|
||||
{isMobile && mobileMenuOpen && (
|
||||
@@ -135,15 +190,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
|
||||
onValueChange={(value) => {
|
||||
const newStoreId = value === "all" ? null : parseInt(value);
|
||||
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'));
|
||||
}
|
||||
});
|
||||
// Invalidation des données du magasin après le rendu (voir plus haut)
|
||||
if (newStoreId !== selectedStoreId) {
|
||||
storeChangePendingRef.current = true;
|
||||
}
|
||||
|
||||
// Sauvegarder dans localStorage et mettre à jour l'état
|
||||
if (newStoreId) {
|
||||
@@ -210,7 +260,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'
|
||||
}`} 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>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -40,13 +40,36 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editComment, setEditComment] = useState("");
|
||||
|
||||
const commentsQueryKey = [`/api/deliveries/${deliveryId}/reconciliation-comments`];
|
||||
|
||||
// Récupérer les commentaires
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`],
|
||||
queryKey: commentsQueryKey,
|
||||
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
|
||||
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
|
||||
const createCommentMutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
@@ -57,10 +80,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire ajouté avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
adjustCommentsCount(1);
|
||||
setIsAdding(false);
|
||||
setNewComment("");
|
||||
},
|
||||
@@ -83,10 +104,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
// Le nombre de commentaires ne change pas : la liste des livraisons reste valable
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
setEditingId(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -108,10 +127,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
adjustCommentsCount(-1);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
import { lazy, useEffect } from "react";
|
||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import AuthPage from "@/pages/AuthPage";
|
||||
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";
|
||||
|
||||
// Mobile pages
|
||||
// Mobile : coque et page d'accueil chargées immédiatement
|
||||
import MobileApp from "@/pages/mobile/MobileApp";
|
||||
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
||||
import MobileOrdersPage from "@/pages/mobile/OrdersPage";
|
||||
import MobileDeliveriesPage from "@/pages/mobile/DeliveriesPage";
|
||||
import MobileCalendarPage from "@/pages/mobile/CalendarPage";
|
||||
import MobileTasksPage from "@/pages/mobile/TasksPage";
|
||||
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
|
||||
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
|
||||
import MobileDlcPage from "@/pages/mobile/DlcPage";
|
||||
import MobileSavPage from "@/pages/mobile/SavPage";
|
||||
import MobileAvoirsPage from "@/pages/mobile/AvoirsPage";
|
||||
|
||||
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
|
||||
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
|
||||
// ni la bibliothèque de graphiques. Le <Suspense> est placé dans la zone de
|
||||
// contenu de Layout et de MobileApp, le menu et l'en-tête restent affichés.
|
||||
const Calendar = lazy(() => import("@/pages/Calendar"));
|
||||
const Orders = lazy(() => import("@/pages/Orders"));
|
||||
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"));
|
||||
|
||||
// Helper component to handle redirection
|
||||
const RedirectToAuth = () => {
|
||||
@@ -53,28 +54,9 @@ const RedirectToAuth = () => {
|
||||
};
|
||||
|
||||
function RouterProduction() {
|
||||
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
|
||||
const { isAuthenticated, isLoading, user } = useAuthUnified();
|
||||
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
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -89,9 +71,6 @@ function RouterProduction() {
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated || !user) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔐 Not authenticated, showing auth routes');
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/auth" component={AuthPage} />
|
||||
@@ -128,14 +107,9 @@ function RouterProduction() {
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
||||
|
||||
{/* Redirection /auth vers dashboard */}
|
||||
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
return <MobileDashboardPage />;
|
||||
}}
|
||||
<Redirect to="/dashboard" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={MobileDashboardPage} />
|
||||
@@ -175,17 +149,10 @@ function RouterProduction() {
|
||||
<Route path="/database-debug" 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">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
return <Dashboard />;
|
||||
}}
|
||||
<Redirect to="/" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={Dashboard} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useAuthSimple } from "@/hooks/useAuthSimple";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, isLoading, error } = useAuthSimple();
|
||||
const { user, isLoading, error } = useAuthUnified();
|
||||
const [location] = useLocation();
|
||||
const { sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen } = useStore();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -218,6 +218,8 @@ export default function Sidebar() {
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/" && location === "/") return true;
|
||||
// L'accueil « / » affiche le tableau de bord (même logique que le menu mobile)
|
||||
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
|
||||
if (path !== "/" && location.startsWith(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -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 { TrendingUp, Package, Truck, Clock } from "lucide-react";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
|
||||
interface StatsPanelProps {
|
||||
currentDate?: Date;
|
||||
@@ -10,14 +9,13 @@ interface StatsPanelProps {
|
||||
|
||||
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
|
||||
const { selectedStoreId } = useStore();
|
||||
const { user } = useAuthUnified();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
|
||||
// Construire l'URL avec les paramètres
|
||||
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
|
||||
queryFn: async () => {
|
||||
const response = await fetch(statsUrl, {
|
||||
@@ -30,6 +28,10 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
||||
|
||||
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) {
|
||||
@@ -58,7 +60,8 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
||||
Statistiques du mois
|
||||
</CardTitle>
|
||||
</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="text-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>({
|
||||
queryKey: ['/api/weather/current'],
|
||||
refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,6 +15,9 @@ import { format } from "date-fns";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -45,20 +48,29 @@ export default function CreateDeliveryModal({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// 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
|
||||
? groupsData.filter(g => g.id === selectedStoreId)
|
||||
: 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[]>({
|
||||
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
|
||||
@@ -74,21 +86,8 @@ export default function CreateDeliveryModal({
|
||||
|
||||
// Auto-sélectionner le magasin selon les règles
|
||||
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
|
||||
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: "" }));
|
||||
return;
|
||||
}
|
||||
@@ -103,21 +102,13 @@ export default function CreateDeliveryModal({
|
||||
} else {
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
}
|
||||
console.log('🏪 Admin delivery store selection:', {
|
||||
selectedStoreId,
|
||||
defaultGroupId,
|
||||
firstGroupId: groups[0].id,
|
||||
groupsAvailable: groups.map(g => g.name)
|
||||
});
|
||||
} else {
|
||||
// Pour les autres rôles : prendre le premier magasin attribué
|
||||
// (La logique existante filtre déjà les groupes selon les permissions)
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
console.log('🏪 Non-admin delivery store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
@@ -148,7 +139,6 @@ export default function CreateDeliveryModal({
|
||||
});
|
||||
|
||||
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
||||
console.log('🚚 Delivery created, clearing cache for consistency');
|
||||
queryClient.invalidateQueries({ predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
const firstKey = key[0]?.toString() || '';
|
||||
@@ -273,7 +263,7 @@ export default function CreateDeliveryModal({
|
||||
<>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: selectedGroup.color }}
|
||||
style={{ backgroundColor: selectedGroup.color || undefined }}
|
||||
/>
|
||||
<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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +14,9 @@ import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import { format } from "date-fns";
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -41,34 +44,23 @@ export default function CreateOrderModal({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// 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
|
||||
? groupsData.filter(g => g.id === selectedStoreId)
|
||||
: groupsData
|
||||
) : [];
|
||||
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
||||
|
||||
// Auto-sélectionner le magasin selon les règles
|
||||
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
|
||||
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: "" }));
|
||||
return;
|
||||
}
|
||||
@@ -83,20 +75,12 @@ export default function CreateOrderModal({
|
||||
} else {
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
}
|
||||
console.log('🏪 Admin store selection:', {
|
||||
selectedStoreId,
|
||||
defaultGroupId,
|
||||
firstGroupId: groups[0].id,
|
||||
groupsAvailable: groups.map(g => g.name)
|
||||
});
|
||||
} else {
|
||||
// Pour les autres rôles : prendre le premier magasin attribué
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
console.log('🏪 Non-admin store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
||||
}
|
||||
|
||||
if (defaultGroupId) {
|
||||
console.log('🏪 Setting default group ID:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
|
||||
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
||||
}
|
||||
}
|
||||
@@ -104,9 +88,7 @@ export default function CreateOrderModal({
|
||||
|
||||
const createOrderMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🚀 Creating order with data:', data);
|
||||
const response = await apiRequest("/api/orders", "POST", data);
|
||||
console.log('✅ Order created successfully:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -114,8 +96,6 @@ export default function CreateOrderModal({
|
||||
title: "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
|
||||
queryClient.invalidateQueries({ predicate: (query) => {
|
||||
@@ -210,7 +190,7 @@ export default function CreateOrderModal({
|
||||
<>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: selectedGroup.color }}
|
||||
style={{ backgroundColor: selectedGroup.color || undefined }}
|
||||
/>
|
||||
<span className="font-medium">{selectedGroup.name}</span>
|
||||
</>
|
||||
|
||||
@@ -47,8 +47,16 @@ export default function EditDeliveryModal({
|
||||
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[]>({
|
||||
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
|
||||
@@ -178,7 +186,7 @@ export default function EditDeliveryModal({
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
style={{ backgroundColor: group.color || undefined }}
|
||||
/>
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function EditOrderModal({
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
style={{ backgroundColor: group.color || undefined }}
|
||||
/>
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,10 @@ export default function ValidateDeliveryModal({
|
||||
title: "Succès",
|
||||
description: "Livraison validée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Invalider tous les caches BL/Rapprochement avec toutes les variations de clés
|
||||
queryClient.invalidateQueries({
|
||||
// Invalider tous les caches liés aux livraisons et les caches BL/Rapprochement
|
||||
// avec toutes les variations de clés, en un seul appel (un second appel sur
|
||||
// ['/api/deliveries'] relançait le chargement des listes affichées)
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === '/api/deliveries/bl' ||
|
||||
query.queryKey[0] === '/api/deliveries'
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useState, useEffect } from "react"
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
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(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
|
||||
@@ -10,30 +10,28 @@ export interface ScreenBreakpoints {
|
||||
|
||||
const defaultBreakpoints: ScreenBreakpoints = {
|
||||
mobile: 768,
|
||||
tablet: 1024,
|
||||
tablet: 1024,
|
||||
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) {
|
||||
const [screenSize, setScreenSize] = useState<ScreenSize>('desktop')
|
||||
const [dimensions, setDimensions] = useState({
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
})
|
||||
// Palier calculé dès le premier rendu (pas de passage transitoire par 'desktop')
|
||||
const [screenSize, setScreenSize] = useState<ScreenSize>(() =>
|
||||
typeof window !== 'undefined' ? getScreenSize(window.innerWidth, breakpoints) : 'desktop'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const getScreenSize = (width: number): ScreenSize => {
|
||||
if (width < breakpoints.mobile) return 'mobile'
|
||||
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
// Seul le palier est stocké : setScreenSize avec la même valeur ne provoque
|
||||
// pas de re-rendu, donc l'arbre n'est re-rendu que lorsque le palier change
|
||||
// et non à chaque pixel de redimensionnement
|
||||
const updateScreenSize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
|
||||
setDimensions({ width, height })
|
||||
setScreenSize(getScreenSize(width))
|
||||
setScreenSize(getScreenSize(window.innerWidth, breakpoints))
|
||||
}
|
||||
|
||||
// Set initial values
|
||||
@@ -41,18 +39,25 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('resize', updateScreenSize)
|
||||
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', updateScreenSize)
|
||||
}, [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 {
|
||||
screenSize,
|
||||
dimensions,
|
||||
isMobile: screenSize === 'mobile',
|
||||
isTablet: screenSize === 'tablet',
|
||||
isTablet: screenSize === 'tablet',
|
||||
isDesktop: screenSize === 'desktop',
|
||||
isMobileOrTablet: screenSize === 'mobile' || screenSize === 'tablet',
|
||||
isTabletOrDesktop: screenSize === 'tablet' || screenSize === 'desktop'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,3 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useAuth() {
|
||||
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,
|
||||
};
|
||||
}
|
||||
// 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 { useAuthUnified as useAuth } from './useAuthUnified';
|
||||
@@ -1,70 +1,3 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Hook d'authentification spécifique pour la production
|
||||
// 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'
|
||||
};
|
||||
}
|
||||
// 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 { useAuthUnified as useAuthProduction } from './useAuthUnified';
|
||||
@@ -1,63 +1,3 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Hook d'authentification simplifié qui fonctionne dans tous les environnements
|
||||
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
|
||||
};
|
||||
}
|
||||
// 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 { useAuthUnified as useAuthSimple } from './useAuthUnified';
|
||||
@@ -1,242 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AUTH_QUERY_KEY, fetchCurrentUser } from "@/lib/queryClient";
|
||||
|
||||
// Hook d'authentification unifié qui s'adapte automatiquement
|
||||
// En production utilise fetch direct, en développement utilise React Query
|
||||
// Conservé dans le retour du hook pour compatibilité (utilisé pour le débogage)
|
||||
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() {
|
||||
// Détection d'environnement plus robuste
|
||||
const isDevelopment = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.includes('replit.dev')) &&
|
||||
import.meta.env.DEV === true;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Debug logging uniquement en développement
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 Auth Environment Debug:', {
|
||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
|
||||
isDev: import.meta.env.DEV,
|
||||
environment: isDevelopment ? 'development' : 'production'
|
||||
});
|
||||
}
|
||||
|
||||
// État pour la version production (fetch direct)
|
||||
const [productionUser, setProductionUser] = useState<any>(null);
|
||||
const [productionLoading, setProductionLoading] = useState(true);
|
||||
const [productionError, setProductionError] = useState<any>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Hook React Query pour le développement
|
||||
const developmentQuery = useQuery({
|
||||
queryKey: ["/api/user"],
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes de cache pour l'auth
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
enabled: isDevelopment, // Seulement en développement
|
||||
const authQuery = useQuery<any>({
|
||||
queryKey: AUTH_QUERY_KEY,
|
||||
queryFn: fetchCurrentUser,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Fonction pour rafraîchir l'authentification
|
||||
const refreshAuth = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
|
||||
}
|
||||
if (!isDevelopment) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Triggering production auth refresh');
|
||||
}
|
||||
setRefreshTrigger(prev => {
|
||||
const newValue = prev + 1;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
|
||||
}
|
||||
return newValue;
|
||||
// Rafraîchissement en arrière-plan : tous les composants abonnés reçoivent
|
||||
// le nouvel utilisateur quand la réponse arrive
|
||||
const refreshAuth = useCallback(() => {
|
||||
return queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEY });
|
||||
}, [queryClient]);
|
||||
|
||||
// Rechargement immédiat (après connexion) : attend la réponse du serveur,
|
||||
// met à jour le cache partagé et renvoie l'utilisateur (ou null)
|
||||
const forceAuthRefresh = useCallback(async () => {
|
||||
try {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: AUTH_QUERY_KEY,
|
||||
queryFn: fetchCurrentUser,
|
||||
staleTime: 0,
|
||||
});
|
||||
} else {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Development mode - using React Query refresh');
|
||||
}
|
||||
developmentQuery.refetch();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du rafraîchissement de l'authentification:", error);
|
||||
return null;
|
||||
}
|
||||
}, [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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,26 @@ export const getQueryFn: <T>(options: {
|
||||
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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -77,15 +97,22 @@ export const queryClient = new QueryClient({
|
||||
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: (failureCount, error: any) => {
|
||||
const message: string = error?.message ?? '';
|
||||
// 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') {
|
||||
console.log('🔄 Query 401 - Redirecting to auth page');
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
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: {
|
||||
@@ -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 "./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 />);
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -52,11 +52,13 @@ export default function Analytics() {
|
||||
return params.toString();
|
||||
}, [dateRange, selectedSuppliers, selectedStores, selectedStatus]);
|
||||
|
||||
// Récupération des données
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||
// Récupération des données. Au changement de filtre (période, fournisseur,
|
||||
// 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],
|
||||
queryFn: async () => {
|
||||
console.log('📊 [ANALYTICS] Fetching summary with params:', queryParams);
|
||||
const response = await fetch(`/api/analytics/summary?${queryParams}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
@@ -64,18 +66,16 @@ export default function Analytics() {
|
||||
console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText);
|
||||
throw new Error('Failed to fetch summary');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('✅ [ANALYTICS] Summary data received:', data);
|
||||
return data;
|
||||
return response.json();
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: isRefreshing ? 30000 : false
|
||||
});
|
||||
|
||||
const { data: timeseries } = useQuery({
|
||||
const { data: timeseries, isPlaceholderData: timeseriesUpdating } = useQuery({
|
||||
queryKey: ['/api/analytics/timeseries', queryParams, granularity],
|
||||
queryFn: async () => {
|
||||
const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`;
|
||||
console.log('📊 [ANALYTICS] Fetching timeseries:', url);
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
@@ -83,13 +83,12 @@ export default function Analytics() {
|
||||
console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText);
|
||||
throw new Error('Failed to fetch timeseries');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('✅ [ANALYTICS] Timeseries data received:', data?.length || 0, 'entries');
|
||||
return data;
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: bySupplier } = useQuery({
|
||||
const { data: bySupplier, isPlaceholderData: bySupplierUpdating } = useQuery({
|
||||
queryKey: ['/api/analytics/by-supplier', queryParams],
|
||||
queryFn: async () => {
|
||||
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');
|
||||
return response.json();
|
||||
}
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: byStore } = useQuery({
|
||||
const { data: byStore, isPlaceholderData: byStoreUpdating } = useQuery({
|
||||
queryKey: ['/api/analytics/by-store', queryParams],
|
||||
queryFn: async () => {
|
||||
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');
|
||||
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({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
@@ -161,6 +166,12 @@ export default function Analytics() {
|
||||
<p className="text-gray-600 mt-1">Analysez vos performances logistiques</p>
|
||||
</div>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -330,7 +341,7 @@ export default function Analytics() {
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">
|
||||
@@ -395,7 +406,7 @@ export default function Analytics() {
|
||||
{/* Graphiques */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Évolution temporelle */}
|
||||
<Card>
|
||||
<Card className={updatingClass(timeseriesUpdating)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
@@ -429,7 +440,7 @@ export default function Analytics() {
|
||||
</Card>
|
||||
|
||||
{/* Répartition par fournisseur */}
|
||||
<Card>
|
||||
<Card className={updatingClass(bySupplierUpdating)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChart className="h-5 w-5" />
|
||||
@@ -471,7 +482,7 @@ export default function Analytics() {
|
||||
</Card>
|
||||
|
||||
{/* Performance par magasin */}
|
||||
<Card>
|
||||
<Card className={updatingClass(byStoreUpdating)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Store className="h-5 w-5" />
|
||||
@@ -511,7 +522,7 @@ export default function Analytics() {
|
||||
</Card>
|
||||
|
||||
{/* Top fournisseurs */}
|
||||
<Card>
|
||||
<Card className={updatingClass(summaryUpdating)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
|
||||
@@ -62,13 +62,19 @@ export default function AuthPage() {
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: "Redirection en cours...",
|
||||
description: "Bienvenue dans LogiFlow",
|
||||
});
|
||||
|
||||
// En production, recharger la page pour éviter les problèmes de synchronisation
|
||||
setTimeout(() => {
|
||||
// L'utilisateur est relu une fois et placé dans le cache partagé : toute
|
||||
// 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 = "/";
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
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(() => {
|
||||
if (defaultCredentialsCheck) {
|
||||
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 { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
@@ -21,6 +21,7 @@ import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
|
||||
// Types
|
||||
interface Avoir {
|
||||
@@ -108,6 +109,58 @@ type AvoirUpdateData = AvoirFormData & {
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState("pending");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -122,10 +175,8 @@ export default function Avoirs() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
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 [processingSeconds, setProcessingSeconds] = useState(0);
|
||||
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// États pour le système de vérification de facture
|
||||
const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({});
|
||||
@@ -143,10 +194,8 @@ export default function Avoirs() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
// Fetch user profile
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
// Utilisateur connecté : cache partagé par toute l'application (pas de requête supplémentaire)
|
||||
const { user } = useAuthUnified();
|
||||
|
||||
// Fetch groups for store filter
|
||||
const { data: groups = [] } = useQuery<Group[]>({
|
||||
@@ -162,86 +211,110 @@ export default function Avoirs() {
|
||||
|
||||
// Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries)
|
||||
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],
|
||||
queryFn: async () => {
|
||||
console.log('💰 Fetching avoirs from:', avoirsUrl);
|
||||
const response = await fetch(avoirsUrl, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch avoirs');
|
||||
}
|
||||
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 : [];
|
||||
},
|
||||
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(() => {
|
||||
const loadCachedVerifications = async () => {
|
||||
console.log('🔍 LoadCache - Début vérification:', {
|
||||
hasAvoirs: !!avoirs,
|
||||
avoirCount: avoirs?.length || 0
|
||||
});
|
||||
|
||||
if (!avoirs || avoirs.length === 0) return;
|
||||
|
||||
const cachedResults: Record<number, any> = {};
|
||||
|
||||
// Charger les vérifications depuis le cache serveur pour TOUS les avoirs avec référence
|
||||
for (const avoir of avoirs) {
|
||||
console.log('🔍 LoadCache - Avoir analysé:', {
|
||||
id: avoir.id,
|
||||
hasInvoiceRef: !!avoir.invoiceReference?.trim(),
|
||||
const avoirsToVerify = avoirs.filter(avoir =>
|
||||
avoir.invoiceReference?.trim() &&
|
||||
!avoir.nocodbVerified &&
|
||||
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
|
||||
);
|
||||
if (avoirsToVerify.length === 0) return;
|
||||
|
||||
// Marquage immédiat : un rechargement pendant la vérification ne la double pas
|
||||
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
|
||||
|
||||
const cachedResults: Record<number, any> = {};
|
||||
runVerificationTasks(avoirsToVerify.map(avoir => async () => {
|
||||
try {
|
||||
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
|
||||
invoiceReference: avoir.invoiceReference,
|
||||
nocodbVerified: avoir.nocodbVerified
|
||||
forceRefresh: false // Utiliser le cache si disponible
|
||||
});
|
||||
|
||||
// Si l'avoir a une référence de facture, vérifier le cache serveur
|
||||
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
|
||||
}
|
||||
if (result?.exists !== undefined) {
|
||||
cachedResults[avoir.id] = result;
|
||||
}
|
||||
} catch {
|
||||
// Vérification silencieuse : la loupe reste disponible pour réessayer
|
||||
}
|
||||
|
||||
console.log('🔍 LoadCache - Résultats finaux:', {
|
||||
cachedCount: Object.keys(cachedResults).length,
|
||||
cachedResults
|
||||
});
|
||||
|
||||
})).then(() => {
|
||||
if (Object.keys(cachedResults).length > 0) {
|
||||
setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults }));
|
||||
console.log('✅ Vérifications chargées depuis le serveur:', cachedResults);
|
||||
}
|
||||
};
|
||||
|
||||
loadCachedVerifications();
|
||||
});
|
||||
}, [avoirs]);
|
||||
|
||||
// Create avoir mutation
|
||||
@@ -262,11 +335,7 @@ export default function Avoirs() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||
}
|
||||
});
|
||||
invalidateAvoirs();
|
||||
setIsCreateDialogOpen(false);
|
||||
toast({
|
||||
title: "Avoir créé",
|
||||
@@ -300,11 +369,7 @@ export default function Avoirs() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||
}
|
||||
});
|
||||
invalidateAvoirs();
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedAvoir(null);
|
||||
toast({
|
||||
@@ -335,11 +400,7 @@ export default function Avoirs() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||
}
|
||||
});
|
||||
invalidateAvoirs();
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedAvoir(null);
|
||||
toast({
|
||||
@@ -411,13 +472,6 @@ export default function Avoirs() {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -498,35 +552,23 @@ export default function Avoirs() {
|
||||
// Handle validation/devalidation
|
||||
const handleValidateAvoir = async (avoirId: number) => {
|
||||
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', {
|
||||
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é
|
||||
setAvoirVerificationResults(prev => ({
|
||||
...prev,
|
||||
[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
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
||||
|
||||
// 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();
|
||||
|
||||
toast({
|
||||
title: "Avoir validé",
|
||||
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
|
||||
return updated;
|
||||
});
|
||||
|
||||
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId);
|
||||
// L'avoir sera revérifié au rechargement de la liste
|
||||
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({
|
||||
title: "Avoir dévalidé",
|
||||
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
|
||||
// (seulement si le montant change : sinon la mise à jour serait inutile)
|
||||
if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) {
|
||||
const avoir = avoirs?.find(a => a?.id === variables.avoirId);
|
||||
if (avoir && avoir.supplierId && avoir.groupId) {
|
||||
try {
|
||||
console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount });
|
||||
editAvoirMutation.mutate({
|
||||
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);
|
||||
}
|
||||
if (avoir && avoir.supplierId && avoir.groupId && amountDiffers(avoir.amount, result.invoiceAmount)) {
|
||||
editAvoirMutation.mutate({
|
||||
id: variables.avoirId,
|
||||
data: buildAmountUpdate(avoir, result.invoiceAmount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,13 +700,8 @@ export default function Avoirs() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 Déclenchement vérification avoir:', {
|
||||
avoirId: avoir.id,
|
||||
invoiceReference: avoir.invoiceReference,
|
||||
supplier: avoir.supplier?.name,
|
||||
group: avoir.group?.name
|
||||
});
|
||||
|
||||
// Référence vérifiée : le rechargement de la liste ne la revérifiera pas
|
||||
verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || '');
|
||||
setVerifyingAvoirs(prev => new Set(prev).add(avoir.id));
|
||||
|
||||
verifyAvoirInvoiceMutation.mutate({
|
||||
@@ -685,8 +711,11 @@ export default function Avoirs() {
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour vérifier tous les avoirs avec une référence facture
|
||||
const handleVerifyAllAvoirInvoices = () => {
|
||||
// Fonction pour vérifier tous les avoirs avec une référence facture.
|
||||
// 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 =>
|
||||
avoir.invoiceReference?.trim() &&
|
||||
(avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId)
|
||||
@@ -700,21 +729,84 @@ export default function Avoirs() {
|
||||
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({
|
||||
title: "Vérification lancée",
|
||||
description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`,
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript
|
||||
// Les vérifications se font maintenant uniquement à la demande utilisateur
|
||||
setVerifyingAvoirs(prev => {
|
||||
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)
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -743,22 +835,9 @@ export default function Avoirs() {
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const startProcessingTimer = () => {
|
||||
setProcessingSeconds(0);
|
||||
const interval = setInterval(() => {
|
||||
setProcessingSeconds(prev => prev + 1);
|
||||
}, 1000);
|
||||
setProcessingTimeout(interval);
|
||||
};
|
||||
|
||||
const handleCloseWaitingModal = () => {
|
||||
setShowWaitingModal(false);
|
||||
setIsUploading(false);
|
||||
setProcessingSeconds(0);
|
||||
if (processingTimeout) {
|
||||
clearInterval(processingTimeout);
|
||||
setProcessingTimeout(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendAvoir = async () => {
|
||||
@@ -789,7 +868,6 @@ export default function Avoirs() {
|
||||
setShowAvoirModal(false);
|
||||
setShowWaitingModal(true);
|
||||
setIsUploading(true);
|
||||
startProcessingTimer();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -833,11 +911,7 @@ export default function Avoirs() {
|
||||
setSelectedFile(null);
|
||||
|
||||
// Invalider les caches pour recharger
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
|
||||
}
|
||||
});
|
||||
invalidateAvoirs();
|
||||
|
||||
} catch (error: any) {
|
||||
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
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -906,24 +969,27 @@ export default function Avoirs() {
|
||||
}
|
||||
};
|
||||
|
||||
// Séparer les avoirs par statut : Finalisé = Reçu ET validé
|
||||
const pendingAvoirs = avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified));
|
||||
const completedAvoirs = avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified);
|
||||
// Séparer les avoirs par statut : Finalisé = Reçu ET validé (recalculé
|
||||
// seulement quand la liste change, pas à chaque frappe ou résultat de vérification)
|
||||
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
|
||||
const filterAvoirs = (avoirsList: Avoir[]) => {
|
||||
return avoirsList.filter((avoir) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
|
||||
avoir.comment?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredPendingAvoirs = filterAvoirs(pendingAvoirs);
|
||||
const filteredCompletedAvoirs = filterAvoirs(completedAvoirs);
|
||||
// Filtrage par recherche, différée pour garder la saisie fluide
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
const filteredPendingAvoirs = useMemo(
|
||||
() => filterAvoirsBySearch(pendingAvoirs, deferredSearchTerm),
|
||||
[pendingAvoirs, deferredSearchTerm]
|
||||
);
|
||||
const filteredCompletedAvoirs = useMemo(
|
||||
() => filterAvoirsBySearch(completedAvoirs, deferredSearchTerm),
|
||||
[completedAvoirs, deferredSearchTerm]
|
||||
);
|
||||
|
||||
const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role);
|
||||
|
||||
@@ -1243,7 +1309,8 @@ export default function Avoirs() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{renderAvoirTable(filteredPendingAvoirs)}
|
||||
{/* Seul le tableau de l'onglet affiché est construit */}
|
||||
{activeTab === "pending" && renderAvoirTable(filteredPendingAvoirs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -1260,7 +1327,7 @@ export default function Avoirs() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{renderAvoirTable(filteredCompletedAvoirs)}
|
||||
{activeTab === "completed" && renderAvoirTable(filteredCompletedAvoirs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -1691,14 +1758,7 @@ export default function Avoirs() {
|
||||
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
<UploadElapsedTime />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// Backup temporaire pour restaurer si nécessaire
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +12,7 @@ import { useStore } from "@/contexts/StoreContext";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { usePermissions } from "@shared/permissions";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare } from "lucide-react";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -20,6 +20,82 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||
|
||||
// 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_DELIVERIES: any[] = [];
|
||||
|
||||
// Clé de la liste du rapprochement : elle commence par '/api/deliveries', donc
|
||||
// toute invalidation des livraisons (ici ou depuis une autre page) l'atteint
|
||||
const RECONCILIATION_QUERY_KEY = ['/api/deliveries', 'reconciliation'] as const;
|
||||
|
||||
// Filtrage des livraisons par recherche uniquement
|
||||
function filterDeliveriesBySearch(deliveries: any[], searchTerm: string) {
|
||||
if (!searchTerm) return deliveries;
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return deliveries.filter((delivery: any) => {
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
|
||||
delivery.group?.name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Progression affichée dans le modal d'attente (60 s max). 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 WaitingProgress() {
|
||||
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setProcessingSeconds(prev => {
|
||||
if (prev >= 60) {
|
||||
clearInterval(interval);
|
||||
return 60;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 relative">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
Traitement de votre facture en cours...
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Le workflow peut prendre jusqu'à 1 minute.
|
||||
<br />
|
||||
Veuillez patienter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -59,28 +135,77 @@ export default function BLReconciliation() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// État pour le modal d'attente du webhook
|
||||
// État pour le modal d'attente du webhook (compteur dans WaitingProgress)
|
||||
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
||||
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// État pour le système de vérification de facture
|
||||
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
||||
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
|
||||
const [autoVerifiedDeliveries, setAutoVerifiedDeliveries] = useState<Set<number>>(new Set());
|
||||
// Livraisons dont le mail fournisseur est en cours d'envoi
|
||||
const [sendingMailDeliveries, setSendingMailDeliveries] = useState<Set<number>>(new Set());
|
||||
|
||||
// État pour le modal de commentaire
|
||||
const [showCommentModal, setShowCommentModal] = useState(false);
|
||||
const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState<any>(null);
|
||||
|
||||
// Récupérer les fournisseurs pour la logique automatique
|
||||
const { data: suppliers = [] } = useQuery<any[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
|
||||
const { data: supplierMailLogs = [] } = useQuery<any[]>({
|
||||
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
const response = await fetch(`/api/supplier-mail-logs?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch supplier mail logs');
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Dernier envoi réussi par livraison (les logs arrivent triés du plus récent au plus ancien)
|
||||
const lastMailByDelivery = React.useMemo(() => {
|
||||
const map = new Map<number, any>();
|
||||
for (const log of supplierMailLogs) {
|
||||
if (log.status === 'sent' && !map.has(log.deliveryId)) {
|
||||
map.set(log.deliveryId, log);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [supplierMailLogs]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File d'attente des vérifications de facture
|
||||
//
|
||||
// Sans limite, une page de 200 livraisons déclenchait 200 requêtes simultanées
|
||||
// (+ autant de PUT d'auto-remplissage), ce qui saturait le navigateur
|
||||
// (net::ERR_INSUFFICIENT_RESOURCES). On limite donc le nombre de requêtes en
|
||||
// vol et on dédoublonne les livraisons déjà en attente.
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAX_CONCURRENT_VERIFICATIONS = 3;
|
||||
const verificationQueueRef = useRef<Array<{ delivery: any; forceRefresh: boolean; silent: boolean }>>([]);
|
||||
const activeVerificationsRef = useRef(0);
|
||||
// Livraisons en file d'attente ou en cours (dédoublonnage synchrone)
|
||||
const pendingVerificationIdsRef = useRef<Set<number>>(new Set());
|
||||
// Livraisons déjà vérifiées automatiquement : jamais relancées par l'effet
|
||||
const autoRequestedIdsRef = useRef<Set<number>>(new Set());
|
||||
// Invalidation de cache différée : une seule fois quand la file est vidée
|
||||
const needsCacheInvalidationRef = useRef(false);
|
||||
|
||||
// Après une modification de livraison : la liste du rapprochement et les
|
||||
// autres listes de livraisons sont invalidées par préfixe (seules les requêtes
|
||||
// affichées sont rechargées) ; l'échéancier, calculé à partir des mêmes
|
||||
// livraisons, est seulement marqué périmé.
|
||||
const invalidateDeliveries = () => Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
|
||||
]);
|
||||
|
||||
// Fonction de vérification de facture
|
||||
const verifyInvoiceMutation = useMutation({
|
||||
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean }) => {
|
||||
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: boolean }) => {
|
||||
try {
|
||||
const result = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, 'POST', {
|
||||
invoiceReference,
|
||||
@@ -100,67 +225,20 @@ export default function BLReconciliation() {
|
||||
}));
|
||||
|
||||
// Pas de toast - affichage silencieux des coches vertes/rouges
|
||||
|
||||
// Auto-remplissage si facture trouvée (référence facture OU numéro BL)
|
||||
if (result.exists) {
|
||||
console.log('🔍 DEBUG - Résultat complet de vérification:', {
|
||||
deliveryId: variables.deliveryId,
|
||||
exists: result.exists,
|
||||
matchType: result.matchType,
|
||||
invoiceReference: result.invoiceReference,
|
||||
invoiceAmount: result.invoiceAmount,
|
||||
dueDate: result.dueDate,
|
||||
hasInvoiceRef: !!result.invoiceReference,
|
||||
hasAmount: result.invoiceAmount !== undefined && result.invoiceAmount !== null,
|
||||
hasDueDate: !!result.dueDate
|
||||
});
|
||||
|
||||
// Auto-remplir les champs dans la livraison via API
|
||||
const updateData: any = {};
|
||||
|
||||
// Ajouter la référence de facture SEULEMENT si trouvée via BL (pas déjà renseignée)
|
||||
if (result.invoiceReference && result.matchType === 'bl_number') {
|
||||
updateData.invoiceReference = result.invoiceReference;
|
||||
console.log('✅ Ajout invoiceReference:', result.invoiceReference);
|
||||
} else {
|
||||
console.log('⚠️ Pas d\'ajout invoiceReference:', { hasRef: !!result.invoiceReference, matchType: result.matchType });
|
||||
}
|
||||
|
||||
// TOUJOURS mettre à jour le montant si disponible (peu importe le matchType)
|
||||
if (result.invoiceAmount !== undefined && result.invoiceAmount !== null) {
|
||||
updateData.invoiceAmount = result.invoiceAmount;
|
||||
console.log('✅ Ajout invoiceAmount:', result.invoiceAmount);
|
||||
} else {
|
||||
console.log('⚠️ Pas d\'ajout invoiceAmount:', { amount: result.invoiceAmount });
|
||||
}
|
||||
|
||||
// TOUJOURS mettre à jour la date d'échéance si disponible (peu importe le matchType)
|
||||
if (result.dueDate) {
|
||||
updateData.dueDate = result.dueDate;
|
||||
console.log('✅ Ajout dueDate:', result.dueDate);
|
||||
} else {
|
||||
console.log('⚠️ Pas d\'ajout dueDate:', { dueDate: result.dueDate });
|
||||
}
|
||||
|
||||
console.log('📝 Données finales à sauvegarder:', { deliveryId: variables.deliveryId, updateData, matchType: result.matchType });
|
||||
|
||||
// Ne faire l'appel que si on a des données à mettre à jour
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
|
||||
.then(() => {
|
||||
console.log('✅ Données sauvegardées avec succès');
|
||||
// Invalider les caches (pas de refetch pour éviter boucles)
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Erreur auto-remplissage:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Aucune donnée à sauvegarder');
|
||||
}
|
||||
|
||||
// Auto-remplissage : le serveur (verify-invoice) enregistre déjà sur la
|
||||
// livraison la référence, les montants et l'échéance trouvés. Il suffit de
|
||||
// recharger la liste, une seule fois quand la file est vidée : sinon
|
||||
// chaque résultat relancerait un refetch complet, qui relancerait l'effet
|
||||
// de vérification automatique.
|
||||
if (result.exists && (
|
||||
(result.invoiceReference && result.matchType === 'bl_number') ||
|
||||
(result.invoiceAmount !== undefined && result.invoiceAmount !== null) ||
|
||||
result.dueDate
|
||||
)) {
|
||||
needsCacheInvalidationRef.current = true;
|
||||
}
|
||||
|
||||
|
||||
setVerifyingDeliveries(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(variables.deliveryId);
|
||||
@@ -184,56 +262,102 @@ export default function BLReconciliation() {
|
||||
return newSet;
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Erreur de vérification",
|
||||
description: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
variant: "destructive",
|
||||
});
|
||||
// Les vérifications automatiques restent silencieuses (coche rouge) :
|
||||
// sinon une panne réseau génère autant de toasts que de lignes affichées.
|
||||
if (!variables.silent) {
|
||||
toast({
|
||||
title: "Erreur de vérification",
|
||||
description: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Fonction pour déclencher la vérification
|
||||
const handleVerifyInvoice = (delivery: any, forceRefresh: boolean = false) => {
|
||||
// Dépile la file d'attente en respectant la limite de requêtes simultanées
|
||||
const drainVerificationQueue = () => {
|
||||
while (
|
||||
activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS &&
|
||||
verificationQueueRef.current.length > 0
|
||||
) {
|
||||
const next = verificationQueueRef.current.shift();
|
||||
if (!next) break;
|
||||
|
||||
const { delivery, forceRefresh, silent } = next;
|
||||
activeVerificationsRef.current += 1;
|
||||
|
||||
verifyInvoiceMutation
|
||||
.mutateAsync({
|
||||
deliveryId: delivery.id,
|
||||
invoiceReference: delivery.invoiceReference,
|
||||
blNumber: delivery.blNumber,
|
||||
forceRefresh,
|
||||
silent
|
||||
})
|
||||
// Les erreurs sont déjà tracées et affichées par onError
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
activeVerificationsRef.current -= 1;
|
||||
pendingVerificationIdsRef.current.delete(delivery.id);
|
||||
drainVerificationQueue();
|
||||
|
||||
// File vidée : on rafraîchit la liste une seule fois
|
||||
if (
|
||||
activeVerificationsRef.current === 0 &&
|
||||
verificationQueueRef.current.length === 0 &&
|
||||
needsCacheInvalidationRef.current
|
||||
) {
|
||||
needsCacheInvalidationRef.current = false;
|
||||
invalidateDeliveries();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour déclencher la vérification (mise en file d'attente)
|
||||
// silent = déclenchement automatique : aucun toast, seules les coches parlent
|
||||
const handleVerifyInvoice = (
|
||||
delivery: any,
|
||||
forceRefresh: boolean = false,
|
||||
silent: boolean = false
|
||||
) => {
|
||||
// Accepter soit une référence de facture soit un numéro BL
|
||||
const hasInvoiceRef = delivery.invoiceReference?.trim();
|
||||
const hasBlNumber = delivery.blNumber?.trim();
|
||||
|
||||
|
||||
if (!hasInvoiceRef && !hasBlNumber) {
|
||||
toast({
|
||||
title: "Référence manquante",
|
||||
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!silent) {
|
||||
toast({
|
||||
title: "Référence manquante",
|
||||
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!delivery.group?.nocodbTableName && !delivery.group?.nocodbConfigId && !delivery.group?.webhookUrl) {
|
||||
toast({
|
||||
title: "Vérification non disponible",
|
||||
description: "Ce magasin n'a pas de configuration NocoDB",
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!silent) {
|
||||
toast({
|
||||
title: "Vérification non disponible",
|
||||
description: "Ce magasin n'a pas de configuration NocoDB",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 Déclenchement vérification:', {
|
||||
deliveryId: delivery.id,
|
||||
hasInvoiceRef,
|
||||
hasBlNumber,
|
||||
invoiceReference: delivery.invoiceReference,
|
||||
blNumber: delivery.blNumber,
|
||||
supplier: delivery.supplier?.name
|
||||
});
|
||||
|
||||
// Déjà en file d'attente ou en cours : on ne l'ajoute pas une seconde fois
|
||||
if (pendingVerificationIdsRef.current.has(delivery.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingVerificationIdsRef.current.add(delivery.id);
|
||||
verificationQueueRef.current.push({ delivery, forceRefresh, silent });
|
||||
setVerifyingDeliveries(prev => new Set(prev).add(delivery.id));
|
||||
|
||||
verifyInvoiceMutation.mutate({
|
||||
deliveryId: delivery.id,
|
||||
invoiceReference: delivery.invoiceReference,
|
||||
blNumber: delivery.blNumber,
|
||||
forceRefresh
|
||||
});
|
||||
|
||||
drainVerificationQueue();
|
||||
};
|
||||
|
||||
// Fonction pour vérifier toutes les factures avec un bouton
|
||||
@@ -251,11 +375,10 @@ export default function BLReconciliation() {
|
||||
return;
|
||||
}
|
||||
|
||||
deliveriesToVerify.forEach((delivery: any, index: number) => {
|
||||
// Délai échelonné pour éviter la surcharge
|
||||
setTimeout(() => {
|
||||
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
|
||||
}, index * 200); // 200ms entre chaque vérification
|
||||
// La file d'attente limite déjà le nombre de requêtes simultanées :
|
||||
// on peut tout empiler d'un coup sans surcharger le navigateur.
|
||||
deliveriesToVerify.forEach((delivery: any) => {
|
||||
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -264,11 +387,14 @@ export default function BLReconciliation() {
|
||||
});
|
||||
};
|
||||
|
||||
// Récupérer les livraisons validées avec BL - CACHE INVALIDÉ après modifications
|
||||
const { data: deliveriesWithBL = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
||||
// Récupérer les livraisons livrées (onglets manuel et validées). Toute
|
||||
// modification de livraison invalide ['/api/deliveries'], donc cette liste :
|
||||
// le cache par défaut (30 s) évite de la retélécharger à chaque visite.
|
||||
const { data: deliveriesWithBL = NO_DELIVERIES, isLoading } = useQuery<any[]>({
|
||||
queryKey: [...RECONCILIATION_QUERY_KEY, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({});
|
||||
// Seules les livraisons livrées sont demandées (filtre appliqué en base)
|
||||
const params = new URLSearchParams({ status: 'delivered' });
|
||||
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
@@ -283,122 +409,100 @@ export default function BLReconciliation() {
|
||||
|
||||
const deliveries = await response.json();
|
||||
|
||||
// Debug désactivé en production pour éviter latence
|
||||
if (import.meta.env.DEV && deliveries[0]) {
|
||||
console.log('🔍 DEBUG - Première livraison:', deliveries[0]);
|
||||
}
|
||||
// Filtre conservé par sécurité si le serveur ignore le paramètre status
|
||||
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());
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 0 // Éviter la mise en cache pour toujours avoir les dernières données BL
|
||||
});
|
||||
|
||||
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
|
||||
//
|
||||
// Cet effet ne dépend QUE des livraisons : y ajouter
|
||||
// verificationResults/verifyingDeliveries le relançait à chaque résultat reçu,
|
||||
// ce qui reprogrammait en boucle les mêmes vérifications.
|
||||
// Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être
|
||||
// effectif immédiatement, sans attendre un re-rendu.
|
||||
useEffect(() => {
|
||||
if (!deliveriesWithBL.length || !suppliers.length) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Déclenchement vérifications automatiques...');
|
||||
}
|
||||
|
||||
// Pré-populer les résultats pour les livraisons déjà réconciliées
|
||||
const newVerificationResults = { ...verificationResults };
|
||||
let hasNewReconciledResults = false;
|
||||
|
||||
if (!deliveriesWithBL.length) return;
|
||||
|
||||
// Résultats déduits localement (sans appel réseau) pour afficher les coches
|
||||
const cachedResults: Record<number, any> = {};
|
||||
|
||||
deliveriesWithBL.forEach((delivery: any) => {
|
||||
// Si la livraison est déjà réconciliée, marquer comme vérifiée avec succès
|
||||
if (delivery.reconciled && !verificationResults[delivery.id]) {
|
||||
newVerificationResults[delivery.id] = {
|
||||
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
|
||||
|
||||
if (delivery.reconciled) {
|
||||
// Livraison déjà réconciliée → coche verte sans vérification
|
||||
cachedResults[delivery.id] = {
|
||||
exists: true,
|
||||
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
||||
fromCache: true,
|
||||
permanent: true,
|
||||
reconciled: true
|
||||
};
|
||||
hasNewReconciledResults = true;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ Livraison ${delivery.id} déjà réconciliée, marquée comme vérifiée`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mettre à jour les résultats si on a de nouvelles livraisons réconciliées
|
||||
if (hasNewReconciledResults) {
|
||||
setVerificationResults(newVerificationResults);
|
||||
}
|
||||
|
||||
// CAS SPÉCIAL : Livraisons réconciliées (✅) avec cellules vides
|
||||
// Si reconciled=true ET (cellules vides) ET blNumber existe → auto-remplir
|
||||
deliveriesWithBL.forEach((delivery: any) => {
|
||||
if (delivery.reconciled) {
|
||||
|
||||
// CAS SPÉCIAL : réconciliée mais cellules vides → auto-remplissage (une seule fois)
|
||||
const hasEmptyCells = !delivery.invoiceReference || !delivery.invoiceAmount || !delivery.dueDate;
|
||||
const hasBLNumber = delivery.blNumber?.trim();
|
||||
const notAlreadyAutoVerified = !autoVerifiedDeliveries.has(delivery.id);
|
||||
const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id);
|
||||
|
||||
if (hasEmptyCells && hasBLNumber && notAlreadyAutoVerified && notCurrentlyVerifying) {
|
||||
// Livraison réconciliée avec cellules vides → vérifier pour auto-remplir (UNE SEULE FOIS)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔄 Livraison réconciliée #${delivery.id} avec cellules vides, auto-vérification (première tentative)...`);
|
||||
}
|
||||
// Marquer comme auto-vérifiée AVANT de lancer pour éviter les doublons
|
||||
setAutoVerifiedDeliveries(prev => new Set(prev).add(delivery.id));
|
||||
// Lancer la vérification sans délai
|
||||
handleVerifyInvoice(delivery, false);
|
||||
|
||||
if (hasEmptyCells && hasBLNumber && !autoRequestedIdsRef.current.has(delivery.id)) {
|
||||
autoRequestedIdsRef.current.add(delivery.id);
|
||||
handleVerifyInvoice(delivery, false, true);
|
||||
}
|
||||
return; // Autres livraisons réconciliées = AUCUNE vérification nécessaire
|
||||
return;
|
||||
}
|
||||
|
||||
// VÉRIFICATION AUTOMATIQUE pour afficher les coches
|
||||
// NE vérifier QUE les factures qui n'ont PAS encore de montant renseigné (pas encore trouvées)
|
||||
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
|
||||
const hasNoInvoiceAmount = !delivery.invoiceAmount; // Pas encore trouvée dans NocoDB
|
||||
const notAlreadyVerified = !verificationResults[delivery.id];
|
||||
const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id);
|
||||
|
||||
// Ne vérifier que si : a des données ET pas de montant (pas encore trouvée) ET pas déjà vérifiée
|
||||
if (hasVerifiableData && hasNoInvoiceAmount && notAlreadyVerified && notCurrentlyVerifying) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔍 Vérification initiale ${delivery.id} (pas encore trouvée):`, {
|
||||
invoiceRef: delivery.invoiceReference,
|
||||
blNumber: delivery.blNumber
|
||||
});
|
||||
}
|
||||
|
||||
// Délai pour éviter de surcharger le serveur
|
||||
setTimeout(() => {
|
||||
handleVerifyInvoice(delivery, false);
|
||||
}, Math.random() * 1000);
|
||||
}
|
||||
|
||||
// Si la facture a déjà un montant → marquer comme trouvée (coche verte) sans vérifier
|
||||
if (hasVerifiableData && delivery.invoiceAmount && !verificationResults[delivery.id]) {
|
||||
newVerificationResults[delivery.id] = {
|
||||
|
||||
// Facture déjà trouvée (montant renseigné) → coche verte sans appel réseau
|
||||
if (hasVerifiableData && delivery.invoiceAmount) {
|
||||
cachedResults[delivery.id] = {
|
||||
exists: true,
|
||||
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
||||
fromCache: true,
|
||||
permanent: true,
|
||||
invoiceAmount: delivery.invoiceAmount
|
||||
};
|
||||
hasNewReconciledResults = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon : vérification réseau, une seule fois par livraison
|
||||
if (hasVerifiableData && !autoRequestedIdsRef.current.has(delivery.id)) {
|
||||
autoRequestedIdsRef.current.add(delivery.id);
|
||||
handleVerifyInvoice(delivery, false, true);
|
||||
}
|
||||
});
|
||||
}, [deliveriesWithBL, suppliers, verificationResults, verifyingDeliveries]);
|
||||
|
||||
// Séparer les livraisons : non validées manuelles et toutes les validées
|
||||
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find(s => s.id === delivery.supplierId);
|
||||
const isManual = supplier?.automaticReconciliation !== true;
|
||||
// Application des résultats déduits, sans écraser ceux déjà obtenus
|
||||
setVerificationResults(prev => {
|
||||
const merged = { ...prev };
|
||||
let changed = false;
|
||||
|
||||
Object.entries(cachedResults).forEach(([id, result]) => {
|
||||
if (!merged[Number(id)]) {
|
||||
merged[Number(id)] = result;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? merged : prev;
|
||||
});
|
||||
}, [deliveriesWithBL]);
|
||||
|
||||
// Séparer les livraisons : non validées manuelles et toutes les validées.
|
||||
// Le fournisseur (dont automaticReconciliation) est joint à chaque livraison.
|
||||
const manualNotValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||
const isManual = delivery.supplier?.automaticReconciliation !== true;
|
||||
const isNotValidated = delivery.reconciled !== true && delivery.reconciled !== 1;
|
||||
return isManual && isNotValidated;
|
||||
});
|
||||
}), [deliveriesWithBL]);
|
||||
|
||||
const allValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
return delivery.reconciled === true || delivery.reconciled === 1;
|
||||
});
|
||||
// Fournisseurs en rapprochement automatique : toujours considérés validés,
|
||||
// même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto)
|
||||
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||
return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1;
|
||||
}), [deliveriesWithBL]);
|
||||
|
||||
// Fonctions de gestion
|
||||
const handleOpenModal = (delivery: any) => {
|
||||
@@ -413,9 +517,13 @@ export default function BLReconciliation() {
|
||||
|
||||
const handleSaveReconciliation = async () => {
|
||||
try {
|
||||
// Force refetch immédiat pour mettre à jour l'affichage
|
||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Mise à jour de l'affichage : seules les listes affichées sont rechargées.
|
||||
// cancelRefetch: false réutilise le rechargement déjà lancé par la modale
|
||||
// (après l'enregistrement) au lieu d'en relancer un second.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }, { cancelRefetch: false }),
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
|
||||
]);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -453,25 +561,6 @@ export default function BLReconciliation() {
|
||||
|
||||
const handleCloseWaitingModal = () => {
|
||||
setShowWaitingModal(false);
|
||||
setProcessingSeconds(0);
|
||||
if (processingTimeout) {
|
||||
clearTimeout(processingTimeout);
|
||||
setProcessingTimeout(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startProcessingTimer = () => {
|
||||
setProcessingSeconds(0);
|
||||
const interval = setInterval(() => {
|
||||
setProcessingSeconds(prev => {
|
||||
if (prev >= 60) {
|
||||
clearInterval(interval);
|
||||
return 60;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 1000);
|
||||
setProcessingTimeout(interval);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -510,7 +599,6 @@ export default function BLReconciliation() {
|
||||
setShowInvoiceModal(false);
|
||||
setShowWaitingModal(true);
|
||||
setIsUploading(true);
|
||||
startProcessingTimer();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -545,19 +633,13 @@ export default function BLReconciliation() {
|
||||
|
||||
// Relancer la vérification de la facture qui vient d'être traitée
|
||||
try {
|
||||
// Invalidation des caches pour forcer le rechargement des données
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
|
||||
// Recharger les données
|
||||
await refetch();
|
||||
// Recharger les livraisons (une seule requête pour la liste affichée)
|
||||
await invalidateDeliveries();
|
||||
|
||||
// Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique
|
||||
setTimeout(() => {
|
||||
handleVerifyInvoice(selectedDeliveryForInvoice, true);
|
||||
}, 1000);
|
||||
|
||||
console.log('🔄 Vérification automatique relancée pour la livraison traitée par webhook');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la relance de la vérification:', error);
|
||||
}
|
||||
@@ -593,20 +675,66 @@ export default function BLReconciliation() {
|
||||
|
||||
// Et il faut qu'il y ait un magasin assigné avec un webhook
|
||||
const hasValidGroup = delivery.group && delivery.group.webhookUrl;
|
||||
|
||||
// Debug uniquement en développement
|
||||
if (import.meta.env.DEV && delivery && delivery.supplier?.name) {
|
||||
console.log(`🔍 Debug bouton facture pour ${delivery.supplier.name}:`, {
|
||||
isNotValidated,
|
||||
hasNoInvoiceReference,
|
||||
hasValidGroup,
|
||||
shouldShow: (isNotValidated || hasNoInvoiceReference) && hasValidGroup
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
||||
};
|
||||
|
||||
// Email du fournisseur : la fiche fournisseur complète est jointe à la livraison
|
||||
const getSupplierEmail = (delivery: any): string => {
|
||||
const email = delivery?.supplier?.email;
|
||||
return email && String(email).trim() ? String(email).trim() : '';
|
||||
};
|
||||
|
||||
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
|
||||
// Le mail part du serveur SMTP configuré sur la fiche du magasin.
|
||||
const sendSupplierMailMutation = useMutation({
|
||||
mutationFn: async (delivery: any) => {
|
||||
return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST');
|
||||
},
|
||||
onSuccess: (result: any) => {
|
||||
toast({
|
||||
title: "Mail envoyé",
|
||||
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
|
||||
});
|
||||
// Rafraîchir l'historique pour marquer la ligne comme relancée
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/supplier-mail-logs'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Envoi impossible",
|
||||
description: error?.message || "Le mail n'a pas pu être envoyé",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, delivery: any) => {
|
||||
setSendingMailDeliveries(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(delivery.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRequestDocumentsByEmail = (delivery: any) => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
|
||||
if (!supplierEmail) {
|
||||
toast({
|
||||
title: "Email fournisseur manquant",
|
||||
description: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}. Ajoutez-la depuis la page Contacts ou Fournisseurs.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Envoi direct : on bloque la ligne le temps de la requête pour éviter
|
||||
// qu'un double clic ne déclenche deux mails au même fournisseur.
|
||||
if (sendingMailDeliveries.has(delivery.id)) return;
|
||||
|
||||
setSendingMailDeliveries(prev => new Set(prev).add(delivery.id));
|
||||
sendSupplierMailMutation.mutate(delivery);
|
||||
};
|
||||
|
||||
const handleQuickValidate = async (delivery: any) => {
|
||||
try {
|
||||
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
|
||||
@@ -620,7 +748,7 @@ export default function BLReconciliation() {
|
||||
});
|
||||
|
||||
// Mise à jour optimiste du cache local pour disparition immédiate
|
||||
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
|
||||
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.map((d: any) =>
|
||||
d.id === delivery.id
|
||||
@@ -629,12 +757,11 @@ export default function BLReconciliation() {
|
||||
);
|
||||
});
|
||||
|
||||
// Force refetch pour synchroniser avec le serveur
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Synchronisation avec le serveur (listes affichées uniquement)
|
||||
invalidateDeliveries();
|
||||
} catch (error) {
|
||||
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
// En cas d'erreur, rechargement pour resynchroniser la liste
|
||||
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider le rapprochement",
|
||||
@@ -668,9 +795,8 @@ export default function BLReconciliation() {
|
||||
description: "Rapprochement dévalidé avec succès",
|
||||
});
|
||||
|
||||
// Force refetch immédiat pour déplacer la facture dans l'onglet manuel
|
||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Rechargement immédiat pour déplacer la facture dans l'onglet manuel
|
||||
await invalidateDeliveries();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
@@ -703,17 +829,16 @@ export default function BLReconciliation() {
|
||||
});
|
||||
|
||||
// Mise à jour optimiste du cache local pour disparition immédiate
|
||||
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
|
||||
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((d: any) => d.id !== deliveryId);
|
||||
});
|
||||
|
||||
// Force refetch pour synchroniser avec le serveur
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Synchronisation avec le serveur (listes affichées uniquement)
|
||||
invalidateDeliveries();
|
||||
} catch (error) {
|
||||
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
|
||||
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
// En cas d'erreur, rechargement pour resynchroniser la liste
|
||||
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la livraison",
|
||||
@@ -722,23 +847,17 @@ export default function BLReconciliation() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage des livraisons par recherche uniquement
|
||||
const filterDeliveries = (deliveries: any[]) => {
|
||||
if (!searchTerm) return deliveries;
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return deliveries.filter((delivery: any) => {
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
|
||||
delivery.group?.name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredManualDeliveries = filterDeliveries(manualNotValidatedDeliveries);
|
||||
const filteredValidatedDeliveries = filterDeliveries(allValidatedDeliveries);
|
||||
// Filtrage par recherche, recalculé seulement quand la liste ou la recherche
|
||||
// change ; la recherche différée garde la saisie fluide sur une longue liste
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
const filteredManualDeliveries = useMemo(
|
||||
() => filterDeliveriesBySearch(manualNotValidatedDeliveries, deferredSearchTerm),
|
||||
[manualNotValidatedDeliveries, deferredSearchTerm]
|
||||
);
|
||||
const filteredValidatedDeliveries = useMemo(
|
||||
() => filterDeliveriesBySearch(allValidatedDeliveries, deferredSearchTerm),
|
||||
[allValidatedDeliveries, deferredSearchTerm]
|
||||
);
|
||||
|
||||
// Pagination pour les rapprochements manuels
|
||||
const {
|
||||
@@ -1043,6 +1162,41 @@ export default function BLReconciliation() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
const lastMail = lastMailByDelivery.get(delivery.id);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`h-8 w-8 p-0 ${
|
||||
!supplierEmail
|
||||
? 'text-gray-400 hover:text-gray-500'
|
||||
: lastMail
|
||||
? 'text-green-600 hover:text-green-700 border-green-300'
|
||||
: 'text-blue-600 hover:text-blue-700 border-blue-300'
|
||||
}`}
|
||||
title={
|
||||
isSending
|
||||
? "Envoi en cours..."
|
||||
: !supplierEmail
|
||||
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: lastMail
|
||||
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
|
||||
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className={`h-4 w-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{shouldShowInvoiceButton(delivery) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1259,21 +1413,11 @@ export default function BLReconciliation() {
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedValidatedDeliveries.map((delivery: any) => {
|
||||
const supplier = suppliers.find(s => s.id === delivery.supplierId);
|
||||
const isAutomatic = supplier?.automaticReconciliation === true;
|
||||
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||
const ecart = delivery.blAmount && delivery.invoiceAmount ?
|
||||
((parseFloat(delivery.invoiceAmount) - parseFloat(delivery.blAmount)) / parseFloat(delivery.blAmount) * 100).toFixed(1) :
|
||||
null;
|
||||
|
||||
// Log pour déboguer le problème des commentaires
|
||||
if (import.meta.env.DEV && delivery.reconciliationCommentsCount) {
|
||||
console.log('🔍 Validated delivery comments:', {
|
||||
deliveryId: delivery.id,
|
||||
commentsCount: delivery.reconciliationCommentsCount,
|
||||
hasComments: delivery.reconciliationCommentsCount > 0
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -1351,6 +1495,39 @@ export default function BLReconciliation() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
const lastMail = lastMailByDelivery.get(delivery.id);
|
||||
return (
|
||||
<button
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
|
||||
!supplierEmail
|
||||
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
: lastMail
|
||||
? 'text-green-600 hover:text-green-700 hover:bg-green-50'
|
||||
: 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||
}`}
|
||||
title={
|
||||
isSending
|
||||
? "Envoi en cours..."
|
||||
: !supplierEmail
|
||||
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: lastMail
|
||||
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
|
||||
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className={`w-4 h-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
onClick={() => handleOpenCommentModal(delivery)}
|
||||
className={`transition-colors duration-200 p-1 rounded ${
|
||||
@@ -1497,36 +1674,7 @@ export default function BLReconciliation() {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 relative">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
Traitement de votre facture en cours...
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Le workflow peut prendre jusqu'à 1 minute.
|
||||
<br />
|
||||
Veuillez patienter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
|
||||
</div>
|
||||
</div>
|
||||
<WaitingProgress />
|
||||
|
||||
{selectedDeliveryForInvoice && (
|
||||
<div className="bg-blue-50 p-3 rounded-md">
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 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({
|
||||
queryKey: ['/api/backups'],
|
||||
queryFn: () => apiRequest('/api/backups'),
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CalendarGrid from "@/components/CalendarGrid";
|
||||
@@ -16,6 +16,9 @@ import { format, startOfMonth, endOfMonth } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -30,36 +33,20 @@ export default function Calendar() {
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const startDate = format(startOfMonth(currentDate), 'yyyy-MM-dd');
|
||||
const endDate = format(endOfMonth(currentDate), 'yyyy-MM-dd');
|
||||
|
||||
// Fetch orders and deliveries for the current month with store filtering
|
||||
const { data: orders = [], isLoading: loadingOrders } = useQuery({
|
||||
queryKey: ['/api/orders', selectedStoreId, {
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
}],
|
||||
const { data: orders = NO_ITEMS, isLoading: loadingOrders, isPlaceholderData: ordersPlaceholder } = useQuery({
|
||||
queryKey: ['/api/orders', selectedStoreId, { startDate, endDate }],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
const params = new URLSearchParams({ startDate, endDate });
|
||||
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/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, {
|
||||
const response = await fetch(`/api/orders?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
@@ -68,9 +55,6 @@ export default function Calendar() {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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({
|
||||
queryKey: ['/api/deliveries', selectedStoreId, {
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
}],
|
||||
const { data: deliveries = NO_ITEMS, isLoading: loadingDeliveries, isPlaceholderData: deliveriesPlaceholder } = useQuery({
|
||||
queryKey: ['/api/deliveries', selectedStoreId, { startDate, endDate }],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
const params = new URLSearchParams({ startDate, endDate });
|
||||
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/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, {
|
||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
@@ -117,9 +89,6 @@ export default function Calendar() {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
const { data: publicities = [], isLoading: loadingPublicities } = useQuery({
|
||||
const { data: publicities = NO_ITEMS } = useQuery({
|
||||
queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -142,17 +114,7 @@ export default function Calendar() {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/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, {
|
||||
const response = await fetch(`/api/ad-campaigns?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
@@ -161,11 +123,12 @@ export default function Calendar() {
|
||||
}
|
||||
|
||||
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 : [];
|
||||
},
|
||||
// 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') => {
|
||||
@@ -178,16 +141,18 @@ export default function Calendar() {
|
||||
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);
|
||||
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
|
||||
setSelectedItem({ ...item, type });
|
||||
setShowOrderDetail(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateOrder = () => {
|
||||
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 (
|
||||
<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>
|
||||
) : (
|
||||
<CalendarGrid
|
||||
currentDate={currentDate}
|
||||
orders={orders || []}
|
||||
deliveries={deliveries || []}
|
||||
publicities={publicities || []}
|
||||
selectedStoreId={selectedStoreId}
|
||||
userGroups={user?.userGroups || []}
|
||||
onDateClick={handleDateClick}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<div
|
||||
className={`transition-opacity ${isMonthLoading ? 'opacity-60' : ''}`}
|
||||
aria-busy={isMonthLoading}
|
||||
>
|
||||
<CalendarGrid
|
||||
currentDate={currentDate}
|
||||
orders={orders}
|
||||
deliveries={deliveries}
|
||||
publicities={publicities}
|
||||
selectedStoreId={selectedStoreId}
|
||||
userGroups={user?.userGroups || NO_ITEMS}
|
||||
onDateClick={handleDateClick}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type { Supplier, Contact, Group } from "@shared/schema";
|
||||
|
||||
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
|
||||
const CAN_EDIT_SUPPLIER = ["admin", "directeur"];
|
||||
|
||||
export default function Contacts() {
|
||||
const { user } = useAuthUnified();
|
||||
@@ -32,6 +33,7 @@ export default function Contacts() {
|
||||
const { stores, selectedStoreId } = useStore();
|
||||
|
||||
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";
|
||||
|
||||
// Pour les admins, permettre de choisir un magasin
|
||||
@@ -41,33 +43,40 @@ export default function Contacts() {
|
||||
const [supplierSearch, setSupplierSearch] = useState("");
|
||||
const [contactSearch, setContactSearch] = useState("");
|
||||
|
||||
// Modal contacts libres
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
groupId: effectiveGroupId ?? 0,
|
||||
name: "",
|
||||
company: "",
|
||||
role: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
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[]>({
|
||||
queryKey: ["/api/suppliers"],
|
||||
});
|
||||
|
||||
const contactsQueryKey = effectiveGroupId
|
||||
? [`/api/contacts?groupId=${effectiveGroupId}`]
|
||||
: ["/api/contacts"];
|
||||
|
||||
// Clé structurée : l'invalidation par préfixe ["/api/contacts"] des mutations
|
||||
// ci-dessous rafraîchit aussi la liste filtrée par magasin
|
||||
const { data: contacts = [] } = useQuery<Contact[]>({
|
||||
queryKey: contactsQueryKey,
|
||||
queryFn: async () => {
|
||||
const url = effectiveGroupId
|
||||
? `/api/contacts?groupId=${effectiveGroupId}`
|
||||
: "/api/contacts";
|
||||
return await apiRequest(url, "GET");
|
||||
},
|
||||
queryKey: ["/api/contacts", effectiveGroupId ?? null],
|
||||
queryFn: () =>
|
||||
apiRequest(effectiveGroupId ? `/api/contacts?groupId=${effectiveGroupId}` : "/api/contacts", "GET"),
|
||||
// Sans magasin, un non-admin ne voit pas la liste : pas de requête
|
||||
enabled: !!user && (isAdmin || !!effectiveGroupId),
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
setSelectedContact(null);
|
||||
setFormData({
|
||||
groupId: effectiveGroupId ?? 0,
|
||||
name: "",
|
||||
company: "",
|
||||
role: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
@@ -123,6 +146,7 @@ export default function Contacts() {
|
||||
setFormData({
|
||||
groupId: contact.groupId,
|
||||
name: contact.name,
|
||||
company: (contact as any).company || "",
|
||||
role: contact.role || "",
|
||||
phone: contact.phone || "",
|
||||
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) =>
|
||||
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
|
||||
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
|
||||
@@ -260,6 +294,11 @@ export default function Contacts() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canEditSupplier && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditSupplier(supplier)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 pl-12">
|
||||
{supplier.phone && (
|
||||
@@ -344,6 +383,9 @@ export default function Contacts() {
|
||||
</div>
|
||||
<div>
|
||||
<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 && (
|
||||
<p className="text-sm text-gray-500">{contact.role}</p>
|
||||
)}
|
||||
@@ -445,6 +487,16 @@ export default function Contacts() {
|
||||
/>
|
||||
</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>
|
||||
<Label htmlFor="c-role">Fonction / Rôle</Label>
|
||||
<Input
|
||||
@@ -505,6 +557,56 @@ export default function Contacts() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -30,13 +30,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
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 { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
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 { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
|
||||
import { ConfirmationModal } from "@/components/ConfirmationModal";
|
||||
@@ -44,6 +43,10 @@ import ClientCallsModal from "@/components/modals/ClientCallsModal";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -65,13 +68,11 @@ export default function CustomerOrders() {
|
||||
const [filterSupplier, setFilterSupplier] = 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
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
@@ -79,11 +80,11 @@ export default function CustomerOrders() {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pending calls');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('📞 Customer Orders - Pending calls received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
||||
return data;
|
||||
return response.json();
|
||||
},
|
||||
enabled: canViewPendingCalls,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
refetchIntervalInBackground: false, // pas de rafraîchissement onglet masqué
|
||||
});
|
||||
|
||||
// Fetch suppliers for filter
|
||||
@@ -93,21 +94,20 @@ export default function CustomerOrders() {
|
||||
|
||||
// Fetch customer orders with store filtering for admins
|
||||
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],
|
||||
// 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
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
console.log("🔥 CREATE MUTATION STARTED with data:", data);
|
||||
return apiRequest('/api/customer-orders', 'POST', data);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
console.log("✅ CREATE MUTATION SUCCESS:", result);
|
||||
// Force refresh of the query with store context
|
||||
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||
onSuccess: () => {
|
||||
// invalidateQueries recharge les listes affichées et marque les autres
|
||||
// comme périmées (pas de second rechargement forcé)
|
||||
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);
|
||||
toast({
|
||||
title: "Succès",
|
||||
@@ -243,15 +243,7 @@ export default function CustomerOrders() {
|
||||
};
|
||||
|
||||
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);
|
||||
console.log("🎯 createMutation.mutate CALLED");
|
||||
};
|
||||
|
||||
const handleEditOrder = (data: any) => {
|
||||
@@ -298,7 +290,8 @@ export default function CustomerOrders() {
|
||||
};
|
||||
|
||||
// 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 {
|
||||
// Créer un canvas temporaire
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -318,8 +311,6 @@ export default function CustomerOrders() {
|
||||
const checksum = calculateEAN13Checksum(processedCode);
|
||||
const ean13 = processedCode + checksum;
|
||||
|
||||
console.log('Code original:', code, 'EAN13 généré:', ean13);
|
||||
|
||||
// Générer le code-barres avec jsbarcode
|
||||
JsBarcode(canvas, ean13, {
|
||||
format: "EAN13",
|
||||
@@ -378,11 +369,32 @@ export default function CustomerOrders() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintLabel = (order: CustomerOrderWithRelations) => {
|
||||
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette
|
||||
const handlePrintLabel = async (order: CustomerOrderWithRelations) => {
|
||||
// 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');
|
||||
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:');
|
||||
|
||||
printWindow.document.write(`
|
||||
@@ -653,74 +665,67 @@ export default function CustomerOrders() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter orders based on search term, supplier, and status
|
||||
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => {
|
||||
// Search term filter
|
||||
const matchesSearch = !searchTerm ||
|
||||
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;
|
||||
}) : [];
|
||||
// Filtre et tri recalculés seulement quand la liste ou un critère change
|
||||
// (et non à chaque rendu, par exemple à chaque frappe dans le commentaire d'appel)
|
||||
const sortedOrders = useMemo(() => {
|
||||
if (!Array.isArray(customerOrders)) return NO_ORDERS;
|
||||
|
||||
// Sort orders - Active orders first, then "Retiré" orders last
|
||||
const sortedOrders = (() => {
|
||||
// Séparer les commandes en deux groupes
|
||||
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
|
||||
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
|
||||
|
||||
// Fonction de tri commune
|
||||
const sortFunction = (a: any, b: any) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
const search = searchTerm.toLowerCase();
|
||||
|
||||
// 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) {
|
||||
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":
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
return order.status;
|
||||
case "supplier":
|
||||
aValue = a.supplier?.name || "";
|
||||
bValue = b.supplier?.name || "";
|
||||
break;
|
||||
default:
|
||||
const defaultDateA = safeDate(a.createdAt);
|
||||
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;
|
||||
return order.supplier?.name || "";
|
||||
case "date":
|
||||
default: {
|
||||
const date = safeDate(order.createdAt);
|
||||
return date ? date.getTime() : 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Trier chaque groupe séparément
|
||||
const sortedActiveOrders = [...activeOrders].sort(sortFunction);
|
||||
const sortedRetiredOrders = [...retiredOrders].sort(sortFunction);
|
||||
|
||||
// Concaténer : commandes actives d'abord, puis retiré en dernier
|
||||
return [...sortedActiveOrders, ...sortedRetiredOrders];
|
||||
})();
|
||||
|
||||
// Fonction de tri commune
|
||||
const sortGroup = (orders: CustomerOrderWithRelations[]) =>
|
||||
orders
|
||||
.map(order => ({ order, value: getSortValue(order) }))
|
||||
.sort((a, b) => {
|
||||
if (sortOrder === "desc") {
|
||||
return a.value > b.value ? -1 : a.value < b.value ? 1 : 0;
|
||||
} else {
|
||||
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
|
||||
}
|
||||
})
|
||||
.map(entry => entry.order);
|
||||
|
||||
// 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
|
||||
const {
|
||||
@@ -733,6 +738,12 @@ export default function CustomerOrders() {
|
||||
totalItems
|
||||
} = 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 (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Alerte pour les appels clients en attente */}
|
||||
@@ -823,9 +834,19 @@ export default function CustomerOrders() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
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>
|
||||
</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 ? (
|
||||
<div>Chargement...</div>
|
||||
) : (
|
||||
|
||||
@@ -1,54 +1,47 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 type { PublicityWithRelations, DashboardMessage } from "@shared/schema";
|
||||
import AnnouncementCard from "@/components/AnnouncementCard";
|
||||
import type { PublicityWithRelations } from "@shared/schema";
|
||||
import AnnouncementCard, { getAnnouncementsQueryOptions, type AnnouncementWithRelations } from "@/components/AnnouncementCard";
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
||||
const [showDlcAlertModal, setShowDlcAlertModal] = useState(false);
|
||||
|
||||
// Invalider les queries DLC quand le magasin change
|
||||
useEffect(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
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();
|
||||
},
|
||||
});
|
||||
// Toutes les requêtes attendent l'utilisateur (enabled: !!user) : le storeId envoyé
|
||||
// dépend de son rôle, et une réponse obtenue sans lui resterait en cache sous la
|
||||
// clé du magasin sélectionné
|
||||
|
||||
// Statistiques annuelles pour la carte de délai moyen
|
||||
const { data: yearlyStats } = useQuery({
|
||||
@@ -73,65 +66,29 @@ export default function Dashboard() {
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// 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 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
|
||||
const { data: allOrders = [] } = useQuery({
|
||||
queryKey: [ordersUrl, selectedStoreId],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: allDeliveries = [] } = useQuery({
|
||||
queryKey: [deliveriesUrl, selectedStoreId],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: customerOrders = [] } = useQuery({
|
||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
// Récupérer la dernière annonce récente (créée il y a moins de 2 jours)
|
||||
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;
|
||||
},
|
||||
// Dernière annonce récente (créée il y a moins de 2 jours), extraite de la
|
||||
// requête des annonces partagée avec AnnouncementCard
|
||||
const { data: recentAnnouncement } = useQuery({
|
||||
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
|
||||
select: selectRecentAnnouncement,
|
||||
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[]>({
|
||||
queryKey: ['/api/ad-campaigns', 'upcoming'],
|
||||
queryFn: async () => {
|
||||
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
|
||||
const currentYear = new Date().getFullYear();
|
||||
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();
|
||||
params.append('year', year.toString());
|
||||
// NE PAS filtrer par magasin - on veut toutes les publicités
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
const yearPublicities = await response.json();
|
||||
allPublicities = [...allPublicities, ...yearPublicities];
|
||||
}
|
||||
if (!response.ok) return [];
|
||||
const yearPublicities = await response.json();
|
||||
return Array.isArray(yearPublicities) ? yearPublicities : [];
|
||||
} 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
|
||||
const futurePublicities = allPublicities
|
||||
.filter((publicity: any) => new Date(publicity.startDate) > new Date())
|
||||
const now = 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());
|
||||
|
||||
return futurePublicities;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// 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({
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId, "dashboard"],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
// Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions)
|
||||
@@ -238,6 +200,7 @@ export default function Dashboard() {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin
|
||||
@@ -287,55 +250,66 @@ export default function Dashboard() {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Données dérivées pour les sections
|
||||
const recentOrders = Array.isArray(allOrders) ? allOrders
|
||||
.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
|
||||
// Données dérivées pour les sections, recalculées seulement quand les données
|
||||
// changent. Les tris portent sur des copies : le cache React Query reste intact.
|
||||
|
||||
// Toutes les commandes en attente
|
||||
const pendingOrders = Array.isArray(allOrders) ? allOrders
|
||||
.filter((order: any) => order.status === 'pending')
|
||||
.sort((a: any, b: any) => {
|
||||
const dateA = safeDate(a.createdAt);
|
||||
const dateB = safeDate(b.createdAt);
|
||||
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
|
||||
}) : []; // Afficher toutes les commandes en attente
|
||||
// Toutes les commandes en attente, les plus récentes d'abord
|
||||
const pendingOrders = useMemo(() => {
|
||||
if (!Array.isArray(allOrders)) return [];
|
||||
const createdTime = (order: any) => safeDate(order.createdAt)?.getTime() ?? 0;
|
||||
return allOrders
|
||||
.filter((order: any) => order.status === 'pending')
|
||||
.sort((a: any, b: any) => createdTime(b) - createdTime(a));
|
||||
}, [allOrders]);
|
||||
|
||||
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
|
||||
.filter((d: any) => d.status === 'planned')
|
||||
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
|
||||
.slice(0, 4) : [];
|
||||
// Livraisons : un seul passage pour les livraisons prévues et les compteurs du mois
|
||||
const { upcomingDeliveries, deliveredThisMonth, totalPalettes } = useMemo(() => {
|
||||
const planned: any[] = [];
|
||||
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
|
||||
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 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)
|
||||
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 (
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
@@ -660,14 +615,7 @@ export default function Dashboard() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.isArray(tasks) && tasks.length > 0 ? tasks
|
||||
.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)
|
||||
{Array.isArray(tasks) && tasks.length > 0 ? openTasks
|
||||
.map((task: any) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -35,6 +35,9 @@ import ValidateDeliveryModal from "@/components/modals/ValidateDeliveryModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
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
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const url = deliveriesUrl;
|
||||
console.log('🚚 Fetching deliveries from:', url);
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
const response = await fetch(deliveriesUrl, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
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 : [];
|
||||
},
|
||||
});
|
||||
|
||||
// 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:', {
|
||||
isLoading,
|
||||
deliveriesCount: deliveries?.length,
|
||||
deliveries: deliveries?.slice(0, 2),
|
||||
selectedStoreId,
|
||||
deliveriesUrl
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const groups = Array.isArray(groupsData) ? groupsData : [];
|
||||
// Clé construite sur l'URL (tableau de bord admin avec magasin sélectionné).
|
||||
// Sans magasin, elle vaut ['/api/deliveries'], déjà invalidée : la réinvalider
|
||||
// relancerait une seconde fois le chargement complet des livraisons.
|
||||
const invalidateDeliveriesUrlKey = () => {
|
||||
if (selectedStoreId) {
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const markControlValidatedMutation = useMutation({
|
||||
@@ -123,7 +117,7 @@ export default function Deliveries() {
|
||||
description: "Contrôle validé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
invalidateDeliveriesUrlKey();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -148,8 +142,10 @@ export default function Deliveries() {
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
invalidateDeliveriesUrlKey();
|
||||
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) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -171,16 +167,19 @@ export default function Deliveries() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
console.log('🔍 Filtering delivery:', delivery.id, { searchTerm, statusFilter });
|
||||
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
delivery.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
delivery.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
|
||||
const filteredDeliveries = useMemo(() => {
|
||||
const search = searchTerm.toLowerCase();
|
||||
return deliveries.filter(delivery => {
|
||||
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(search) ||
|
||||
delivery.group?.name.toLowerCase().includes(search) ||
|
||||
delivery.notes?.toLowerCase().includes(search);
|
||||
|
||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [deliveries, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -37,6 +37,14 @@ const dlcFormSchema = z.object({
|
||||
|
||||
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() {
|
||||
const { user, isLoading: authLoading } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
@@ -61,7 +69,7 @@ export default function DlcPage() {
|
||||
}, [searchTerm]);
|
||||
|
||||
// Fetch stores/groups - optimized cache
|
||||
const { data: stores = [] } = useQuery({
|
||||
const { data: stores = [] } = useQuery<any[]>({
|
||||
queryKey: ["/api/groups"],
|
||||
enabled: !authLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
@@ -76,7 +84,7 @@ export default function DlcPage() {
|
||||
});
|
||||
|
||||
// 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],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -89,6 +97,11 @@ export default function DlcPage() {
|
||||
},
|
||||
enabled: !authLoading,
|
||||
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
|
||||
@@ -175,13 +188,30 @@ export default function DlcPage() {
|
||||
};
|
||||
}, [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
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
||||
onSuccess: () => {
|
||||
// Selective cache invalidation
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
||||
invalidateDlcData();
|
||||
toast({ title: "Produit DLC créé avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
@@ -201,8 +231,7 @@ export default function DlcPage() {
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
||||
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
||||
invalidateDlcData();
|
||||
toast({ title: "Produit DLC mis à jour avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
@@ -219,11 +248,12 @@ export default function DlcPage() {
|
||||
|
||||
// Validate mutation - optimized cache invalidation
|
||||
const validateMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "validate"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
||||
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" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -236,11 +266,12 @@ export default function DlcPage() {
|
||||
|
||||
// Delete mutation - optimized cache invalidation
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "delete"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
||||
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" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -253,11 +284,12 @@ export default function DlcPage() {
|
||||
|
||||
// Stock épuisé mutation - accessible à tous
|
||||
const markStockEpuiseMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "stock-epuise"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
|
||||
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é" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -270,11 +302,12 @@ export default function DlcPage() {
|
||||
|
||||
// Restaurer stock mutation - réservé aux admins, directeurs et managers
|
||||
const restoreStockMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "restore-stock"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
|
||||
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" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -287,11 +320,12 @@ export default function DlcPage() {
|
||||
|
||||
// Marquer comme traité mutation - accessible à tous
|
||||
const markProcessedMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "mark-processed"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
||||
toast({ title: "Produit marqué comme traité" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -304,11 +338,12 @@ export default function DlcPage() {
|
||||
|
||||
// Annuler traitement mutation - réservé aux admins, directeurs et managers
|
||||
const unmarkProcessedMutation = useMutation({
|
||||
mutationKey: [DLC_ROW_ACTION_KEY, "unmark-processed"],
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
|
||||
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" });
|
||||
// La ligne reste désactivée jusqu'au rechargement de la liste
|
||||
return invalidateDlcData();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -330,36 +365,21 @@ export default function DlcPage() {
|
||||
if (user?.userGroups?.[0]?.groupId) {
|
||||
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
|
||||
groupId = user.userGroups[0].groupId;
|
||||
console.log("🎯 DLC Frontend: Using user's assigned group (PRIORITY):", groupId);
|
||||
} else if (user?.role === 'admin' && selectedStoreId) {
|
||||
// Admin avec magasin sélectionné ET pas de groupe assigné
|
||||
groupId = selectedStoreId;
|
||||
console.log("🎯 DLC Frontend: Using admin selected store:", groupId);
|
||||
} else if (user?.role === 'admin' && stores?.[0]?.id) {
|
||||
// Admin sans sélection : premier magasin disponible
|
||||
groupId = stores[0].id;
|
||||
console.log("🎯 DLC Frontend: Using first available store for admin:", groupId);
|
||||
} else {
|
||||
// Fallback d'urgence
|
||||
groupId = 1;
|
||||
console.log("🚨 DLC Frontend: Using emergency fallback groupId:", groupId);
|
||||
}
|
||||
|
||||
console.log("🏪 DLC GroupId Selection DEBUG:", {
|
||||
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 = {
|
||||
const dlcData: any = {
|
||||
...data,
|
||||
name: data.productName, // Copier productName vers name (requis dans la DB)
|
||||
expiryDate: dlcDate,
|
||||
expiryDate: dlcDate as any,
|
||||
quantity: 1, // Valeur par défaut
|
||||
unit: "unité", // Valeur par défaut
|
||||
location: "Magasin", // Valeur par défaut
|
||||
@@ -377,7 +397,7 @@ export default function DlcPage() {
|
||||
const handleEdit = (product: DlcProductWithRelations) => {
|
||||
setEditingProduct(product);
|
||||
form.reset({
|
||||
productName: product.productName,
|
||||
productName: product.productName as string,
|
||||
gencode: product.gencode || "",
|
||||
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
|
||||
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
||||
@@ -526,7 +546,7 @@ export default function DlcPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expiringSoon.map(product => {
|
||||
${expiringSoon.map((product: any) => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
@@ -585,7 +605,7 @@ export default function DlcPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expired.map(product => {
|
||||
${expired.map((product: any) => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
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
|
||||
const filteredProducts = dlcProducts || [];
|
||||
const filteredProducts = dlcProducts || NO_PRODUCTS;
|
||||
|
||||
// Memoized calculations for print functions
|
||||
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
|
||||
const today = new Date();
|
||||
const expiringSoon = filteredProducts.filter(product => {
|
||||
const expiringSoon = filteredProducts.filter((product: any) => {
|
||||
if (product.status === 'valides') return false;
|
||||
const expiryDate = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return diffDays <= 15 && diffDays > 0;
|
||||
});
|
||||
|
||||
const expired = filteredProducts.filter(product => {
|
||||
const expired = filteredProducts.filter((product: any) => {
|
||||
if (product.status === 'valides') return false;
|
||||
const expiryDate = new Date(product.dlcDate || new Date());
|
||||
return expiryDate <= today;
|
||||
@@ -647,6 +667,13 @@ export default function DlcPage() {
|
||||
totalItems
|
||||
} = 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) {
|
||||
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
||||
}
|
||||
@@ -926,7 +953,15 @@ export default function DlcPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
@@ -934,14 +969,17 @@ export default function DlcPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productsLoading ? (
|
||||
{productsLoading || (isPlaceholderData && totalItems === 0) ? (
|
||||
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
|
||||
) : totalItems === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun produit DLC trouvé
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
|
||||
aria-busy={isPlaceholderData}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -992,7 +1030,7 @@ export default function DlcPage() {
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleValidate(product.id)}
|
||||
disabled={validateMutation.isPending}
|
||||
disabled={isRowActionPending("validate", product.id)}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
data-testid={`button-validate-${product.id}`}
|
||||
title="Valider définitivement ce produit"
|
||||
@@ -1010,7 +1048,7 @@ export default function DlcPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkProcessed(product.id)}
|
||||
disabled={markProcessedMutation.isPending}
|
||||
disabled={isRowActionPending("mark-processed", product.id)}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||
data-testid={`button-mark-processed-${product.id}`}
|
||||
>
|
||||
@@ -1036,7 +1074,7 @@ export default function DlcPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnmarkProcessed(product.id)}
|
||||
disabled={unmarkProcessedMutation.isPending}
|
||||
disabled={isRowActionPending("unmark-processed", product.id)}
|
||||
className="border-orange-300 text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -1062,7 +1100,7 @@ export default function DlcPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkStockEpuise(product.id)}
|
||||
disabled={markStockEpuiseMutation.isPending}
|
||||
disabled={isRowActionPending("stock-epuise", product.id)}
|
||||
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
|
||||
>
|
||||
<PackageX className="w-4 h-4" />
|
||||
@@ -1087,7 +1125,7 @@ export default function DlcPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRestoreStock(product.id)}
|
||||
disabled={restoreStockMutation.isPending}
|
||||
disabled={isRowActionPending("restore-stock", product.id)}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
@@ -1106,7 +1144,7 @@ export default function DlcPage() {
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={isRowActionPending("delete", product.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
Truck,
|
||||
Palette
|
||||
} from "lucide-react";
|
||||
import type { Group, NocodbConfig } from "@shared/schema";
|
||||
import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema";
|
||||
|
||||
const colorOptions = [
|
||||
{ value: '#1976D2', label: 'Bleu' },
|
||||
@@ -33,6 +34,38 @@ const colorOptions = [
|
||||
{ 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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
@@ -42,55 +75,33 @@ export default function Groups() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
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[]>({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Protection renforcée contre les erreurs TypeError
|
||||
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({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||
// rapatrier l'historique complet des commandes et des livraisons.
|
||||
const { data: groupStats = [] } = useQuery<EntityActivityStats[]>({
|
||||
queryKey: ['/api/stats/by-group'],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🏪 Frontend: Creating group with data:', data);
|
||||
const result = await apiRequest("/api/groups", "POST", data);
|
||||
console.log('🏪 Frontend: Group creation result:', result);
|
||||
return result;
|
||||
return await apiRequest("/api/groups", "POST", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
@@ -99,19 +110,7 @@ export default function Groups() {
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -145,19 +144,7 @@ export default function Groups() {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
},
|
||||
onError: (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({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/groups/${id}`, "DELETE");
|
||||
@@ -214,59 +248,20 @@ export default function Groups() {
|
||||
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 groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
const match = order.groupId === groupId;
|
||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
||||
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
|
||||
});
|
||||
|
||||
const stats = Array.isArray(groupStats)
|
||||
? groupStats.find(s => s.id === groupId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
orders: groupOrders.length,
|
||||
deliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
orders: stats?.orders ?? 0,
|
||||
deliveries: stats?.deliveries ?? 0,
|
||||
delivered: stats?.delivered ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
@@ -284,6 +279,18 @@ export default function Groups() {
|
||||
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
|
||||
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
|
||||
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);
|
||||
};
|
||||
@@ -318,11 +325,17 @@ export default function Groups() {
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
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) {
|
||||
updateMutation.mutate(submitData);
|
||||
} 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 }));
|
||||
};
|
||||
|
||||
@@ -413,7 +426,7 @@ export default function Groups() {
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
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" />
|
||||
</div>
|
||||
@@ -448,7 +461,7 @@ export default function Groups() {
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
@@ -492,16 +505,9 @@ export default function Groups() {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM, 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>
|
||||
<DialogTitle>
|
||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||
@@ -512,6 +518,9 @@ export default function Groups() {
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<Label htmlFor="name">Nom du groupe *</Label>
|
||||
<Input
|
||||
@@ -712,27 +721,222 @@ export default function Groups() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : coordonnées + envoi de mails */}
|
||||
<div className="space-y-4">
|
||||
{/* Section Coordonnées du magasin (signature des mails) */}
|
||||
<div className="border-t md:border-t-0 pt-4 md:pt-0 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Reprises dans la signature des mails envoyés aux fournisseurs.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="address">Adresse</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder={"12 rue des Fleurs\n54000 Nancy"}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Téléphone</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="03 83 00 00 00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logo">Logo</Label>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
{formData.logo ? (
|
||||
<img
|
||||
src={formData.logo}
|
||||
alt="Logo du magasin"
|
||||
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
|
||||
Aucun logo
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleLogoChange}
|
||||
className="text-xs"
|
||||
/>
|
||||
{formData.logo && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-red-600 hover:text-red-700"
|
||||
onClick={() => handleChange('logo', '')}
|
||||
>
|
||||
Retirer le logo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration SMTP du magasin */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-purple-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
|
||||
</div>
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtpEnabled}
|
||||
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Activé</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
|
||||
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
|
||||
</p>
|
||||
|
||||
{formData.smtpEnabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
|
||||
<Input
|
||||
id="smtpHost"
|
||||
value={formData.smtpHost}
|
||||
onChange={(e) => handleChange('smtpHost', e.target.value)}
|
||||
placeholder="smtp.office365.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpPort">Port *</Label>
|
||||
<Input
|
||||
id="smtpPort"
|
||||
type="number"
|
||||
value={formData.smtpPort}
|
||||
onChange={(e) => handleChange('smtpPort', e.target.value)}
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtpSecure}
|
||||
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="smtpUser">Identifiant</Label>
|
||||
<Input
|
||||
id="smtpUser"
|
||||
value={formData.smtpUser}
|
||||
onChange={(e) => handleChange('smtpUser', e.target.value)}
|
||||
placeholder="magasin@lafoirfouille.fr"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpPassword">Mot de passe</Label>
|
||||
<Input
|
||||
id="smtpPassword"
|
||||
type="password"
|
||||
value={formData.smtpPassword}
|
||||
onChange={(e) => handleChange('smtpPassword', e.target.value)}
|
||||
placeholder={
|
||||
selectedGroup && (selectedGroup as any).smtpPasswordSet
|
||||
? "Inchangé"
|
||||
: "Mot de passe"
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
|
||||
<Input
|
||||
id="smtpSenderEmail"
|
||||
type="email"
|
||||
value={formData.smtpSenderEmail}
|
||||
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
|
||||
placeholder="magasin@lafoirfouille.fr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpSenderName">Nom affiché</Label>
|
||||
<Input
|
||||
id="smtpSenderName"
|
||||
value={formData.smtpSenderName}
|
||||
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
|
||||
placeholder={formData.name || "LaFoir'Fouille Nancy"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGroup && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
|
||||
>
|
||||
{testSmtpMutation.isPending
|
||||
? "Test en cours..."
|
||||
: "✉️ Tester la connexion SMTP"}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">
|
||||
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function NocoDBConfig() {
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
const { data: rawConfigs, isLoading } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
@@ -61,21 +61,6 @@ export default function NocoDBConfig() {
|
||||
// Protection quadruple couche pour éviter les erreurs TypeError
|
||||
const configs = rawConfigs || [];
|
||||
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
|
||||
const createConfigMutation = useMutation({
|
||||
@@ -183,7 +168,7 @@ export default function NocoDBConfig() {
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
isActive: config.isActive ?? undefined,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -30,6 +30,9 @@ import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -73,7 +76,7 @@ export default function Orders() {
|
||||
const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
|
||||
const { data: ordersData = [], isLoading } = useQuery<OrderWithRelations[]>({
|
||||
const { data: ordersData = NO_ORDERS, isLoading } = useQuery<OrderWithRelations[]>({
|
||||
queryKey: [ordersUrl, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(ordersUrl, { credentials: 'include' });
|
||||
@@ -81,69 +84,32 @@ export default function Orders() {
|
||||
throw new Error('Failed to fetch orders');
|
||||
}
|
||||
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 : [];
|
||||
},
|
||||
});
|
||||
|
||||
// Production Bug Fix: Ensure array safety for all data operations
|
||||
const orders = Array.isArray(ordersData) ? ordersData : [];
|
||||
|
||||
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 orders = Array.isArray(ordersData) ? ordersData : NO_ORDERS;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
console.log('🗑️ Deleting order:', id);
|
||||
await apiRequest(`/api/orders/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Order deleted successfully, invalidating cache...');
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande supprimée avec succès",
|
||||
});
|
||||
|
||||
console.log('🗑️ Order deleted, clearing ALL cache to avoid inconsistencies:', {
|
||||
ordersUrl,
|
||||
selectedStoreId
|
||||
});
|
||||
|
||||
// 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
|
||||
// Invalidation ciblée sans clear() pour préserver le contexte : les requêtes
|
||||
// affichées sont rechargées tout de suite, les autres (mois du calendrier
|
||||
// déjà visités, autres pages) seulement quand elles seront réaffichées
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey[0]?.toString() || '';
|
||||
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) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -165,16 +131,19 @@ export default function Orders() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter });
|
||||
const matchesSearch = order.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
|
||||
const filteredOrders = useMemo(() => {
|
||||
const search = searchTerm.toLowerCase();
|
||||
return orders.filter(order => {
|
||||
const matchesSearch = order.supplier?.name.toLowerCase().includes(search) ||
|
||||
order.group?.name.toLowerCase().includes(search) ||
|
||||
order.notes?.toLowerCase().includes(search);
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [orders, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -53,6 +53,7 @@ interface PaymentScheduleResponse {
|
||||
export default function PaymentSchedulePage() {
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>(() => {
|
||||
const now = new Date();
|
||||
return format(now, 'yyyy-MM');
|
||||
@@ -75,7 +76,12 @@ export default function PaymentSchedulePage() {
|
||||
if (!response.ok) {
|
||||
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,
|
||||
});
|
||||
@@ -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) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<div className="p-6 space-y-6">
|
||||
{header}
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -292,38 +338,7 @@ export default function PaymentSchedulePage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* En-tête */}
|
||||
<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>
|
||||
{header}
|
||||
|
||||
{/* Statistiques du mois */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -49,6 +49,10 @@ const priorityConfig = {
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
@@ -94,10 +98,15 @@ export default function SavTickets() {
|
||||
|
||||
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],
|
||||
enabled: !!user && (user.role !== 'admin' || storeInitialized),
|
||||
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[]>({
|
||||
@@ -105,7 +114,7 @@ export default function SavTickets() {
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||
});
|
||||
@@ -288,30 +297,32 @@ export default function SavTickets() {
|
||||
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
|
||||
const canDelete = ['admin', 'directeur'].includes(user?.role || '');
|
||||
|
||||
// Get user's available groups for auto-assignment
|
||||
const getUserGroups = () => {
|
||||
// Get user's available groups for auto-assignment (recalculé seulement quand
|
||||
// l'utilisateur ou la liste des magasins change, et non à chaque frappe)
|
||||
const availableGroups = useMemo(() => {
|
||||
if (!user || user.role === 'admin') {
|
||||
console.log('🎫 [CLIENT] Admin user - can access all groups:', groupsData?.length || 0);
|
||||
return groupsData; // Admin can access all groups
|
||||
}
|
||||
|
||||
// For other users, get their assigned groups
|
||||
const userGroups = (user as any).userGroups || [];
|
||||
const filteredGroups = groupsData.filter(group =>
|
||||
return groupsData.filter(group =>
|
||||
userGroups.some((ug: any) => ug.groupId === group.id)
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
}, [user, groupsData]);
|
||||
|
||||
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
|
||||
const handleCreateTicket = () => {
|
||||
@@ -327,17 +338,9 @@ export default function SavTickets() {
|
||||
// Auto-assign group based on user role and available groups
|
||||
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) {
|
||||
// Auto-assign first available group
|
||||
selectedGroupId = availableGroups[0].id;
|
||||
console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name);
|
||||
}
|
||||
|
||||
const ticketData: InsertSavTicket = {
|
||||
@@ -355,14 +358,6 @@ export default function SavTickets() {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -372,13 +367,6 @@ export default function SavTickets() {
|
||||
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)
|
||||
const hasRecentComments = (ticket: SavTicketWithRelations) => {
|
||||
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 (
|
||||
<div className="p-3 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
@@ -823,7 +798,11 @@ export default function SavTickets() {
|
||||
Liste des tickets de service après-vente
|
||||
</CardDescription>
|
||||
</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 ? (
|
||||
<div className="p-8 text-center">
|
||||
<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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
CheckCircle,
|
||||
Clock
|
||||
} 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() {
|
||||
const { user } = useAuthUnified();
|
||||
@@ -52,24 +56,19 @@ export default function Suppliers() {
|
||||
paymentMethod: "",
|
||||
});
|
||||
|
||||
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({
|
||||
const { data: suppliers = NO_SUPPLIERS, isLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||
// rapatrier l'historique complet des commandes et des livraisons.
|
||||
const { data: supplierStats = NO_STATS } = useQuery<EntityActivityStats[]>({
|
||||
queryKey: ['/api/stats/by-supplier'],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🚚 Frontend: Creating supplier with data:', data);
|
||||
const result = await apiRequest("/api/suppliers", "POST", data);
|
||||
console.log('🚚 Frontend: Supplier creation result:', result);
|
||||
return result;
|
||||
return await apiRequest("/api/suppliers", "POST", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
@@ -102,14 +101,10 @@ export default function Suppliers() {
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🔧 Frontend: Updating supplier with data:', data);
|
||||
const result = await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
|
||||
console.log('🔧 Frontend: Supplier update result:', result);
|
||||
return result;
|
||||
return await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
|
||||
},
|
||||
onMutate: async (newData) => {
|
||||
// Mise à jour optimiste - mettre à jour l'interface immédiatement
|
||||
console.log('🚀 Frontend: Optimistic update starting...');
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] });
|
||||
|
||||
const previousSuppliers = queryClient.getQueryData(['/api/suppliers']);
|
||||
@@ -121,14 +116,12 @@ export default function Suppliers() {
|
||||
: supplier
|
||||
);
|
||||
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
|
||||
console.log('✅ Frontend: Optimistic update applied');
|
||||
}
|
||||
|
||||
return { previousSuppliers };
|
||||
},
|
||||
onError: (error, newData, context) => {
|
||||
// Rollback en cas d'erreur
|
||||
console.log('⚠️ Frontend: Rolling back optimistic update due to error');
|
||||
if (context?.previousSuppliers) {
|
||||
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
|
||||
}
|
||||
@@ -155,13 +148,15 @@ export default function Suppliers() {
|
||||
title: "Succès",
|
||||
description: "Fournisseur modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
resetForm();
|
||||
},
|
||||
// Une seule invalidation, après succès comme après échec
|
||||
onSettled: () => {
|
||||
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 =>
|
||||
supplier.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
// Filtre recalculé seulement quand la liste ou la recherche change, terme normalisé une seule fois
|
||||
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 supplierOrders = orders.filter((order: any) => order.supplierId === supplierId);
|
||||
const supplierDeliveries = deliveries.filter((delivery: any) => delivery.supplierId === supplierId);
|
||||
const deliveredCount = supplierDeliveries.filter((delivery: any) => delivery.status === 'delivered').length;
|
||||
|
||||
const stats = statsBySupplierId.get(supplierId);
|
||||
|
||||
return {
|
||||
orders: supplierOrders.length,
|
||||
deliveries: supplierDeliveries.length,
|
||||
delivered: deliveredCount,
|
||||
orders: stats?.orders ?? 0,
|
||||
deliveries: stats?.deliveries ?? 0,
|
||||
delivered: stats?.delivered ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useMemo, useDeferredValue } from "react";
|
||||
import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useScreenSize, type ScreenBreakpoints } from "@/hooks/use-screen-size";
|
||||
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 { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
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 {
|
||||
ListTodo,
|
||||
@@ -42,9 +42,17 @@ type TaskWithRelations = Task & {
|
||||
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
|
||||
function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -91,22 +99,12 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
(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, {
|
||||
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 });
|
||||
@@ -117,8 +115,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
title: "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) {
|
||||
console.error('Erreur:', error);
|
||||
toast({
|
||||
@@ -277,35 +278,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
|
||||
export default function Tasks() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId, storeInitialized } = useStore();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Debug enablement condition for tasks query
|
||||
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]);
|
||||
// Une seule des deux mises en page (tablette ou bureau) est montée
|
||||
const { isDesktop: isLargeScreen } = useScreenSize(TASKS_LAYOUT_BREAKPOINTS);
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -322,9 +299,15 @@ export default function Tasks() {
|
||||
const [selectedTask, setSelectedTask] = 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
|
||||
const { data: tasks = [], isLoading, error } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
// Clé propre à cette page : le tableau de bord utilise ['/api/tasks', storeId]
|
||||
// avec une URL différente pour les non-admins (sans storeId), sa réponse ne doit
|
||||
// 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 () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -332,39 +315,17 @@ export default function Tasks() {
|
||||
if (selectedStoreId) {
|
||||
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()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
if (!Array.isArray(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);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
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
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ["/api/users"],
|
||||
queryFn: () => fetch('/api/users', {
|
||||
credentials: 'include'
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
enabled: !!user && (user.role === 'admin' || user.role === 'manager' || user.role === 'directeur'),
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
// Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
|
||||
// pouvoir le restaurer si le serveur refuse l'opération
|
||||
const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
|
||||
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 handleCompleteTask = async (taskId: number) => {
|
||||
try {
|
||||
const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -416,32 +379,39 @@ export default function Tasks() {
|
||||
if (!response.ok) {
|
||||
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({
|
||||
title: "Succès",
|
||||
description: "Tâche marquée comme terminée",
|
||||
});
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error, _taskId, context) => {
|
||||
restoreCachedTasks(context);
|
||||
console.error("Error completing task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de terminer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskToDelete.id}`, {
|
||||
// Supprimer : la tâche disparaît tout de suite de la liste et la modale se ferme
|
||||
const deleteTaskMutation = useMutation({
|
||||
mutationFn: async (taskId: number) => {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
@@ -449,86 +419,117 @@ export default function Tasks() {
|
||||
if (!response.ok) {
|
||||
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({
|
||||
title: "Succès",
|
||||
description: "Tâche supprimée avec succès",
|
||||
});
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error, _taskId, context) => {
|
||||
restoreCachedTasks(context);
|
||||
console.error("Error deleting task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Filtrer et trier les tâches
|
||||
const filteredTasks = tasks
|
||||
.filter((task: TaskWithRelations) => {
|
||||
// Filtre par recherche
|
||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!task.description?.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
const handleCompleteTask = (taskId: number) => {
|
||||
completeTaskMutation.mutate(taskId);
|
||||
};
|
||||
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Filtre par priorité
|
||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
const handleConfirmDelete = () => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
// 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;
|
||||
deleteTaskMutation.mutate(taskToDelete.id);
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
};
|
||||
|
||||
// La recherche est différée : la saisie reste fluide pendant le filtrage
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
|
||||
// Filtrer et trier les tâches (recalculé seulement quand la liste ou un filtre change)
|
||||
const filteredTasks = useMemo(() => {
|
||||
const search = deferredSearchTerm.toLowerCase();
|
||||
return tasks
|
||||
.filter((task: TaskWithRelations) => {
|
||||
// Filtre par recherche
|
||||
if (search && !task.title.toLowerCase().includes(search) &&
|
||||
!task.description?.toLowerCase().includes(search)) {
|
||||
return false;
|
||||
}
|
||||
} 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;
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
||||
}
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // Ordre décroissant (high 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 bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
// Enfin, trier par date de création (plus récent en premier)
|
||||
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
}, [tasks, deferredSearchTerm, statusFilter, priorityFilter, dueDateFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
@@ -541,6 +542,18 @@ export default function Tasks() {
|
||||
totalItems
|
||||
} = 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) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
@@ -687,6 +700,12 @@ export default function Tasks() {
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1 text-sm sm:text-base">
|
||||
{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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Section filtres - Responsive */}
|
||||
<div className={`${filtersOpen ? 'block' : 'hidden'} lg:hidden mb-4`}>
|
||||
{/* Section filtres - Responsive (tablette uniquement) */}
|
||||
{!isLargeScreen && (
|
||||
<div className={`${filtersOpen ? 'block' : 'hidden'} mb-4`}>
|
||||
<Card className="mb-4 sm:mb-6">
|
||||
<CardHeader className="pb-3 sm:pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -846,9 +866,11 @@ export default function Tasks() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<TabsContent value="list" className="mt-0">
|
||||
{/* Contenu mobile identique au desktop mais optimisé */}
|
||||
@@ -865,14 +887,13 @@ export default function Tasks() {
|
||||
) : (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* Tâches en cours - Mobile optimisé */}
|
||||
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
|
||||
{pagePendingTasks.length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'pending')
|
||||
{pagePendingTasks
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@@ -1024,14 +1045,13 @@ export default function Tasks() {
|
||||
)}
|
||||
|
||||
{/* Tâches terminées - Mobile optimisé */}
|
||||
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && (
|
||||
{pageCompletedTasks.length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'completed')
|
||||
{pageCompletedTasks
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@@ -1088,9 +1108,11 @@ export default function Tasks() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar desktop uniquement */}
|
||||
<div className="hidden lg:flex gap-6">
|
||||
{isLargeScreen && (
|
||||
<div className="flex gap-6">
|
||||
{/* Sidebar avec filtres desktop */}
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
|
||||
<Card>
|
||||
@@ -1167,7 +1189,7 @@ export default function Tasks() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
|
||||
<TabsContent value="list" className="mt-0">
|
||||
@@ -1186,14 +1208,13 @@ export default function Tasks() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Tâches en cours */}
|
||||
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
|
||||
{pagePendingTasks.length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'pending')
|
||||
{pagePendingTasks
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@@ -1334,14 +1355,13 @@ export default function Tasks() {
|
||||
)}
|
||||
|
||||
{/* Tâches terminées */}
|
||||
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && (
|
||||
{pageCompletedTasks.length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'completed')
|
||||
{pageCompletedTasks
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@@ -1426,10 +1446,10 @@ export default function Tasks() {
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Circle className="w-5 h-5 text-yellow-500" />
|
||||
En cours ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').length})
|
||||
En cours ({kanbanPendingTasks.length})
|
||||
</h3>
|
||||
<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 PriorityIcon = priorityConfig.icon;
|
||||
const dueDateStatus = getDueDateStatus(task.dueDate, task.status);
|
||||
@@ -1506,10 +1526,10 @@ export default function Tasks() {
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
Terminées ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').length})
|
||||
Terminées ({kanbanCompletedTasks.length})
|
||||
</h3>
|
||||
<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 PriorityIcon = priorityConfig.icon;
|
||||
|
||||
@@ -1562,6 +1582,7 @@ export default function Tasks() {
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal d'édition */}
|
||||
{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";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useAuthUnified();
|
||||
const { user, refreshAuth } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -132,11 +132,16 @@ export default function UsersPage() {
|
||||
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (updatedUser) => {
|
||||
onSuccess: (updatedUser, variables) => {
|
||||
toast({
|
||||
title: "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
|
||||
if (updatedUser) {
|
||||
@@ -224,25 +229,24 @@ export default function UsersPage() {
|
||||
|
||||
const assignGroupMutation = useMutation({
|
||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||
console.log('📤 Assigning group:', data);
|
||||
const response = await apiRequest(
|
||||
`/api/users/${data.userId}/groups`,
|
||||
"POST",
|
||||
{ groupId: data.groupId }
|
||||
);
|
||||
console.log('✅ Group assignment response:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Group assigned successfully');
|
||||
onSuccess: (_response, variables) => {
|
||||
toast({
|
||||
title: "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/groups'] });
|
||||
if (variables.userId === user?.id) {
|
||||
refreshAuth();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('❌ Error assigning group:', error);
|
||||
@@ -270,24 +274,23 @@ export default function UsersPage() {
|
||||
|
||||
const removeGroupMutation = useMutation({
|
||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||
console.log('🗑️ Removing group:', data);
|
||||
const response = await apiRequest(
|
||||
`/api/users/${data.userId}/groups/${data.groupId}`,
|
||||
"DELETE"
|
||||
);
|
||||
console.log('✅ Group removal response:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Group removed successfully');
|
||||
onSuccess: (_response, variables) => {
|
||||
toast({
|
||||
title: "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/groups'] });
|
||||
if (variables.userId === user?.id) {
|
||||
refreshAuth();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('❌ Error removing group:', error);
|
||||
@@ -737,7 +740,7 @@ export default function UsersPage() {
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-1"
|
||||
style={{ backgroundColor: userGroup.group.color }}
|
||||
style={{ backgroundColor: userGroup.group.color || undefined }}
|
||||
/>
|
||||
{userGroup.group.name}
|
||||
</div>
|
||||
@@ -901,7 +904,7 @@ export default function UsersPage() {
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
style={{ backgroundColor: group.color || undefined }}
|
||||
/>
|
||||
<span className="text-sm">{group.name}</span>
|
||||
</div>
|
||||
@@ -1063,7 +1066,7 @@ export default function UsersPage() {
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
style={{ backgroundColor: group.color || undefined }}
|
||||
/>
|
||||
<Label htmlFor={`group-${group.id}`} className="text-sm">
|
||||
{group.name}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MobileAvoirsPage.tsx
|
||||
* 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 { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
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"),
|
||||
});
|
||||
|
||||
// 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() {
|
||||
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 [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Queries
|
||||
const { data: avoirs = [], isLoading } = useQuery({
|
||||
const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<any[]>({
|
||||
queryKey: ["/api/avoirs", selectedStoreId],
|
||||
queryFn: async () => {
|
||||
if (!selectedStoreId) return [];
|
||||
@@ -88,14 +92,11 @@ export default function MobileAvoirsPage() {
|
||||
enabled: !!selectedStoreId && !!user,
|
||||
});
|
||||
|
||||
// Fournisseurs : utiles seulement dans la feuille de création, chargés à son ouverture
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ['/api/suppliers'],
|
||||
queryFn: () => apiRequest('/api/suppliers')
|
||||
});
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['/api/groups'],
|
||||
queryFn: () => apiRequest('/api/groups')
|
||||
queryFn: () => apiRequest('/api/suppliers'),
|
||||
enabled: isCreateOpen,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
@@ -142,7 +143,7 @@ export default function MobileAvoirsPage() {
|
||||
const onSubmit = (data: any) => {
|
||||
let groupId = selectedStoreId;
|
||||
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) {
|
||||
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());
|
||||
|
||||
const filteredAvoirs = sortedAvoirs.filter((a: any) =>
|
||||
(a.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || "") ||
|
||||
(a.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || "")
|
||||
// Tri et filtrage recalculés seulement quand la liste ou la recherche change
|
||||
const sortedAvoirs = useMemo(
|
||||
() => [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[avoirs]
|
||||
);
|
||||
|
||||
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) => {
|
||||
switch (status) {
|
||||
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">
|
||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||
id: avoir.id,
|
||||
data: { ...avoir, status: 'En attente de demande' }
|
||||
data: buildStatusUpdate(avoir, 'En attente de demande')
|
||||
})}>
|
||||
Marquer En attente
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||
id: avoir.id,
|
||||
data: { ...avoir, status: 'Demandé' }
|
||||
data: buildStatusUpdate(avoir, 'Demandé')
|
||||
})}>
|
||||
Marquer Demandé
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||
id: avoir.id,
|
||||
data: { ...avoir, status: 'Reçu' }
|
||||
data: buildStatusUpdate(avoir, 'Reçu')
|
||||
})}>
|
||||
Marquer Reçu
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* CalendarPage Mobile - Calendrier simplifié pour mobile
|
||||
* Affiche les commandes et livraisons comme événements
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
@@ -42,6 +42,10 @@ type CalendarEvent = {
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -49,7 +53,7 @@ export default function MobileCalendarPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
|
||||
// Fetch orders for current month
|
||||
const { data: orders = [] } = useQuery({
|
||||
const { data: orders = NO_DATA } = useQuery({
|
||||
queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
||||
queryFn: async () => {
|
||||
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
||||
@@ -74,7 +78,7 @@ export default function MobileCalendarPage() {
|
||||
});
|
||||
|
||||
// Fetch deliveries for current month
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
const { data: deliveries = NO_DATA } = useQuery({
|
||||
queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')],
|
||||
queryFn: async () => {
|
||||
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
||||
@@ -98,25 +102,45 @@ export default function MobileCalendarPage() {
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Combine orders and deliveries into events
|
||||
const events: CalendarEvent[] = [
|
||||
...orders.map((order: any) => ({
|
||||
id: order.id,
|
||||
title: `Cmd #${order.orderNumber || order.id}`,
|
||||
date: order.plannedDate,
|
||||
type: 'order' as const,
|
||||
status: order.status,
|
||||
supplier: order.supplier
|
||||
})),
|
||||
...deliveries.map((delivery: any) => ({
|
||||
id: delivery.id,
|
||||
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
||||
date: delivery.scheduledDate,
|
||||
type: 'delivery' as const,
|
||||
status: delivery.status,
|
||||
supplier: delivery.supplier
|
||||
}))
|
||||
];
|
||||
// Combine orders and deliveries into events, indexés par jour ('yyyy-MM-dd',
|
||||
// heure locale comme isSameDay) une seule fois par chargement au lieu d'un
|
||||
// filtrage complet pour chaque case du calendrier
|
||||
const { events, eventsByDay } = useMemo(() => {
|
||||
const events: CalendarEvent[] = [
|
||||
...orders.map((order: any) => ({
|
||||
id: order.id,
|
||||
title: `Cmd #${order.orderNumber || order.id}`,
|
||||
date: order.plannedDate,
|
||||
type: 'order' as const,
|
||||
status: order.status,
|
||||
supplier: order.supplier
|
||||
})),
|
||||
...deliveries.map((delivery: any) => ({
|
||||
id: delivery.id,
|
||||
title: `Liv #${delivery.deliveryNumber || delivery.id}`,
|
||||
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 monthEnd = endOfMonth(currentMonth);
|
||||
@@ -126,24 +150,14 @@ export default function MobileCalendarPage() {
|
||||
const calendarEnd = endOfWeek(monthEnd, { locale: fr });
|
||||
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
|
||||
const getEventsForDay = (day: Date) => {
|
||||
return events.filter((event) => {
|
||||
if (!event.date) return false;
|
||||
const eventDate = new Date(event.date);
|
||||
return !isNaN(eventDate.getTime()) && isSameDay(eventDate, day);
|
||||
});
|
||||
return eventsByDay.get(format(day, 'yyyy-MM-dd')) || NO_EVENTS;
|
||||
};
|
||||
|
||||
// Get events for selected date
|
||||
const selectedDateEvents = selectedDate ? getEventsForDay(selectedDate) : NO_EVENTS;
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
setCurrentMonth(direction === 'prev'
|
||||
? subMonths(currentMonth, 1)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MobileCustomerOrdersPage.tsx
|
||||
* 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 { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
X,
|
||||
Filter,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Search
|
||||
MessageSquare
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
@@ -78,6 +77,9 @@ const customerOrderFormSchema = z.object({
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -89,7 +91,7 @@ export default function MobileCustomerOrdersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Queries
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
const { data: orders = NO_ORDERS, isLoading } = useQuery({
|
||||
queryKey: ["/api/customer-orders", selectedStoreId],
|
||||
queryFn: async () => {
|
||||
if (!selectedStoreId) return [];
|
||||
@@ -268,11 +270,16 @@ export default function MobileCustomerOrdersPage() {
|
||||
createMutation.mutate(submitData);
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter((order: any) =>
|
||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
// Recalculé seulement quand la liste ou la recherche change (et non à
|
||||
// chaque frappe dans le commentaire de contact)
|
||||
const filteredOrders = useMemo(() => {
|
||||
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) => {
|
||||
switch (status) {
|
||||
@@ -553,7 +560,7 @@ export default function MobileCustomerOrdersPage() {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isPromotionalPrice"
|
||||
name={"isPromotionalPrice" as any}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
@@ -573,7 +580,7 @@ export default function MobileCustomerOrdersPage() {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerNotified"
|
||||
name={"customerNotified" as any}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* DashboardPage Mobile - Tableau de bord simplifié pour mobile
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import MobileLayout from "./MobileLayout";
|
||||
@@ -59,22 +60,6 @@ export default function MobileDashboardPage() {
|
||||
const { user } = useAuthUnified();
|
||||
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
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
@@ -92,13 +77,18 @@ export default function MobileDashboardPage() {
|
||||
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
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ["/api/orders/today", selectedStoreId],
|
||||
queryKey: ["/api/orders/today", selectedStoreId, today],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
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}`, {
|
||||
credentials: 'include'
|
||||
@@ -112,11 +102,12 @@ export default function MobileDashboardPage() {
|
||||
|
||||
// Fetch today's deliveries
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ["/api/deliveries/today", selectedStoreId],
|
||||
queryKey: ["/api/deliveries/today", selectedStoreId, today],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
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}`, {
|
||||
credentials: 'include'
|
||||
@@ -183,15 +174,16 @@ export default function MobileDashboardPage() {
|
||||
{ icon: Calendar, label: "Agenda", path: "/calendar" },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
// Lien wouter : navigation interne sans recharger l'application
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
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" />
|
||||
<span className="text-xs text-gray-600">{item.label}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -70,30 +70,40 @@ const dlcFormSchema = z.object({
|
||||
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() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
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
|
||||
const { data: dlcProducts = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm],
|
||||
// Toute la liste du magasin est chargée une seule fois : l'onglet et la
|
||||
// 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 () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId) params.append("storeId", selectedStoreId.toString());
|
||||
// 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 [];
|
||||
if (!selectedStoreId) return [];
|
||||
return apiRequest(`/api/dlc-products?storeId=${selectedStoreId}`);
|
||||
},
|
||||
enabled: !!selectedStoreId && !!user,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes, comme sur ordinateur
|
||||
});
|
||||
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
@@ -230,8 +240,8 @@ export default function MobileDlcPage() {
|
||||
let filtered = dlcProducts;
|
||||
|
||||
// Search
|
||||
if (searchTerm) {
|
||||
const lower = searchTerm.toLowerCase();
|
||||
if (debouncedSearchTerm) {
|
||||
const lower = debouncedSearchTerm.toLowerCase();
|
||||
filtered = filtered.filter((p: any) =>
|
||||
p.productName.toLowerCase().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());
|
||||
}, [dlcProducts, searchTerm, statusFilter]);
|
||||
}, [dlcProducts, debouncedSearchTerm, statusFilter]);
|
||||
|
||||
const getDaysBadge = (dateStr: string) => {
|
||||
const today = new Date();
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
* MobileApp - Wrapper pour l'application mobile avec StoreProvider
|
||||
* 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 { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { StoreProvider } from "@/contexts/StoreContext";
|
||||
import MobileLayout from "./MobileLayout";
|
||||
import type { Group } from "@shared/schema";
|
||||
|
||||
interface MobileAppProps {
|
||||
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) {
|
||||
const { user } = useAuthUnified();
|
||||
|
||||
@@ -26,7 +43,7 @@ export default function MobileApp({ children }: MobileAppProps) {
|
||||
const [storeInitialized, setStoreInitialized] = useState(false);
|
||||
|
||||
// Fetch stores
|
||||
const { data: stores = [] } = useQuery<Group[]>({
|
||||
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
@@ -52,7 +69,9 @@ export default function MobileApp({ children }: MobileAppProps) {
|
||||
}
|
||||
}, [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,
|
||||
setSelectedStoreId,
|
||||
stores,
|
||||
@@ -61,11 +80,13 @@ export default function MobileApp({ children }: MobileAppProps) {
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
storeInitialized
|
||||
};
|
||||
}), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
|
||||
|
||||
return (
|
||||
<StoreProvider value={storeContextValue}>
|
||||
{children}
|
||||
<Suspense fallback={<MobilePageFallback />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</StoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Receipt
|
||||
} from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
@@ -44,7 +43,6 @@ const moreItems: NavItem[] = [
|
||||
|
||||
export default function MobileBottomNav() {
|
||||
const [location] = useLocation();
|
||||
const { user } = useAuthUnified();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -32,9 +32,13 @@ type TaskWithRelations = Task & {
|
||||
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
|
||||
function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -84,7 +88,9 @@ function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
|
||||
title: "Succès",
|
||||
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) {
|
||||
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 [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null);
|
||||
|
||||
const tasksQueryKey = ["/api/tasks", selectedStoreId];
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({
|
||||
queryKey: tasksQueryKey,
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
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) : [];
|
||||
},
|
||||
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
|
||||
const filteredTasks = tasks
|
||||
.filter((task: TaskWithRelations) => {
|
||||
// Tab filter
|
||||
if (activeTab === 'pending' && task.status === 'completed') return false;
|
||||
if (activeTab === 'completed' && task.status !== 'completed') return false;
|
||||
// Filter tasks (recalculé seulement quand la liste, l'onglet ou la recherche change)
|
||||
const filteredTasks = useMemo(() => {
|
||||
const search = searchTerm.toLowerCase();
|
||||
return tasks
|
||||
.filter((task: TaskWithRelations) => {
|
||||
// Tab filter
|
||||
if (activeTab === 'pending' && task.status === 'completed') return false;
|
||||
if (activeTab === 'completed' && task.status !== 'completed') return false;
|
||||
|
||||
// Search filter
|
||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||
// Pending first, then by priority
|
||||
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
|
||||
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
|
||||
});
|
||||
// Search filter
|
||||
if (search && !task.title.toLowerCase().includes(search)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||
// Pending first, then by priority
|
||||
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
|
||||
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
|
||||
});
|
||||
}, [tasks, activeTab, searchTerm]);
|
||||
|
||||
// Handlers
|
||||
const handleComplete = async (taskId: number) => {
|
||||
try {
|
||||
await fetch(`/api/tasks/${taskId}/complete`, {
|
||||
// Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
|
||||
// pouvoir le restaurer si le serveur refuse l'opération
|
||||
const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
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" });
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (_error, _taskId, context) => {
|
||||
restoreCachedTasks(context);
|
||||
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;
|
||||
try {
|
||||
await fetch(`/api/tasks/${deletingTask.id}`, { method: 'DELETE', credentials: 'include' });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({ title: "🗑️ Tâche supprimée" });
|
||||
setDeletingTask(null);
|
||||
} catch (error) {
|
||||
toast({ title: "Erreur", variant: "destructive" });
|
||||
}
|
||||
deleteMutation.mutate(deletingTask.id);
|
||||
setDeletingTask(null);
|
||||
};
|
||||
|
||||
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">
|
||||
<ListTodo className="w-5 h-5 text-blue-600" />
|
||||
<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>
|
||||
<Badge variant="secondary">{filteredTasks.length}</Badge>
|
||||
</div>
|
||||
@@ -423,8 +495,12 @@ export default function MobileTasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="px-3 py-2 overflow-hidden" style={{ maxWidth: '100%' }}>
|
||||
{/* Task List (liste du magasin précédent estompée et non cliquable pendant le chargement) */}
|
||||
<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 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<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_DB: logiflow_db
|
||||
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:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 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
|
||||
|
||||
Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
|
||||
puis redémarrer :
|
||||
|
||||
```
|
||||
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||
```
|
||||
|
||||
Générer une clé :
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Variable vide ou absente : 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.
|
||||
@@ -0,0 +1,463 @@
|
||||
# Rapprochement BL/Factures & Échéancier
|
||||
|
||||
_40 constats vérifiés — 6 haute, 22 moyenne, 12 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/bl-reconciliation`
|
||||
|
||||
**Rôle :** Rapprocher les bons de livraison (BL) des factures fournisseurs : vérifier que la facture existe dans NocoDB, compléter la référence, le montant et l'échéance, valider le rapprochement, relancer le fournisseur par mail, envoyer une facture PDF au webhook du magasin et commenter les écarts.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Voir les livraisons livrées à rapprocher (onglet manuel) et celles déjà validées
|
||||
- Vérifier une facture (loupe, ou bouton « Vérifier toutes les factures »)
|
||||
- Valider ou dévalider un rapprochement
|
||||
- Modifier N° BL, montants, référence facture et échéance (modale)
|
||||
- Demander la facture ou le BL au fournisseur par mail
|
||||
- Envoyer une facture PDF au webhook (modale + attente)
|
||||
- Ajouter, modifier ou supprimer des commentaires de rapprochement
|
||||
- Rechercher par fournisseur, BL, facture ou magasin
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/suppliers | au montage | server/routes.ts:1141 -> storage.getSuppliers (storage.ts:467, SELECT * FROM suppliers) | Redondant : chaque livraison contient déjà supplier complet (jointure storage.ts:912). Bloque l'effet de vérification automatique (BLReconciliation.tsx:387) alors que suppliers n'y est pas utilisé. |
|
||||
| GET /api/supplier-mail-logs?storeId= | au montage (enabled: !!user) | server/routes.ts:2439 -> storage.getSupplierMailLogs (storage.ts:2974, LIMIT 500, index sur delivery_id et group_id) | Correct et borné. storeId ignoré pour le directeur (seul l'admin le prend en compte, routes.ts:2454). |
|
||||
| GET /api/deliveries?storeId= | au montage, staleTime 0 (refetch à chaque visite), queryKey ['/api/deliveries/bl', storeId] | server/routes.ts:1752 -> storage.getDeliveries (storage.ts:885) + attachOrdersAndCommentCounts (storage.ts:643 : loadOrdersByIds + count des commentaires) | Toutes les livraisons du magasin (ou de tous les magasins pour l'admin), tous statuts, sans pagination. Filtrage status==='delivered' fait côté navigateur (BLReconciliation.tsx:371). Chaque ligne embarque la ligne groups complète (logo base64, config SMTP/NocoDB/webhook) et la commande avec à nouveau supplier + group. Pas de compression HTTP. Toute la réponse est clonée récursivement par stripSmtpPassword (routes.ts:168-174). |
|
||||
| POST /api/deliveries/:id/verify-invoice | automatique au montage pour chaque livraison livrée sans montant facture (file de 3 requêtes simultanées), clic sur la loupe, bouton « Vérifier toutes les factures » (forceRefresh) | server/routes.ts:2466 -> getUserWithGroups (2 requêtes) + getDelivery (storage.ts:1004 : jointure + getUser + getOrder [2 requêtes] + count commentaires) + invoiceVerificationService.verifyInvoice/verifyInvoiceByBL (invoiceVerification.ts:249/612 : cache BD, getGroup, getActiveNocodbConfig, 1 à 2 appels NocoDB, upsert cache) + storage.updateDelivery | Environ 10 requêtes séquentielles par livraison, plus NocoDB en cas d'absence en cache. Le serveur enregistre déjà montant, TTC, échéance et référence (routes.ts:2555-2587), mais le client renvoie en plus un PUT identique. |
|
||||
| PUT /api/deliveries/:id | auto-remplissage après chaque vérification réussie, validation rapide, dévalidation, enregistrement de la modale | server/routes.ts:1906 -> getUserWithGroups + getDelivery + (si invoiceReference change) verifyInvoice forceRefresh synchrone + storage.updateDelivery (storage.ts:1136) + (si blNumber présent) storage.getSuppliers() complet | Le PUT d'auto-remplissage fait doublon avec verify-invoice. La modale envoie toujours blNumber, ce qui charge toute la table fournisseurs. Logs complets du corps et de l'objet mis à jour. |
|
||||
| DELETE /api/deliveries/:id | au clic (window.confirm) | server/routes.ts:2136 -> getDelivery + storage.deleteDelivery (storage.ts:1175) | Suivi de refetchQueries sur ['/api/deliveries/bl'] et ['/api/deliveries'] (toutes les requêtes livraisons en cache, même inactives). |
|
||||
| POST /api/deliveries/:id/send-supplier-mail | au clic sur l'icône mail | server/routes.ts:2330 -> getDelivery + storage.getGroup + sendSupplierDocumentRequest + storage.createSupplierMailLog (storage.ts:2969) | OK fonctionnellement ; invalide ensuite /api/supplier-mail-logs. |
|
||||
| POST /api/reconciliation/send-invoice (multipart) | au clic « Envoyer » dans la modale Envoyer Facture | server/routes.ts:883 -> parse multipart manuel + fetch(parts.webhookUrl) sans timeout | L'URL du webhook vient du client (risque SSRF), le corps est lu sans limite de taille, il n'y a de timeout ni côté client ni côté serveur, et la modale d'attente ne peut pas être fermée. Réservé admin/directeur côté serveur, mais le bouton est affiché sans contrôle de rôle. |
|
||||
| GET /api/deliveries/:id/reconciliation-comments | ouverture de la modale commentaires ou de l'onglet Commentaires de ReconciliationModal | server/routes.ts:2175 -> getDelivery + storage.getReconciliationComments (storage.ts:3035) | Sélectionne author: users (ligne complète, hash du mot de passe inclus), group: groups (logo) et une copie de la livraison pour chaque commentaire. |
|
||||
| POST /api/deliveries/:id/reconciliation-comments, PUT/DELETE /api/reconciliation-comments/:id | au clic Ajouter / Enregistrer / Supprimer | server/routes.ts:2211 / 2254 / 2292 -> storage.createReconciliationComment / updateReconciliationComment / deleteReconciliationComment (storage.ts:3142-3163) | Chaque mutation invalide toute la liste des livraisons (['/api/deliveries'] et ['/api/deliveries/bl']) juste pour mettre à jour un compteur. |
|
||||
|
||||
**Lisibilité / simplicité :** La page est dense et technique. Le tableau a 10 colonnes (min-w 900px, même sur mobile) et jusqu'à 6 boutons-icônes par ligne, sans texte, expliqués seulement par l'attribut title (invisible au toucher). Les deux onglets n'ont ni le même ordre de colonnes, ni la même unité d'écart (€ avec seuil 10 € d'un côté, % avec seuil 5 % de l'autre), ni le même style de boutons. Les compteurs apparaissent deux fois (badges d'en-tête et badges d'onglets). Le jargon est fréquent : « Rapprochement Manuel », « AUTO », « Ref. Facture », « Montant Fact. », « webhook », « workflow », URL technique du webhook affichée. Les coches verte et rouge n'expliquent pas pourquoi une facture est introuvable. Le bouton Valider reste grisé avec une consigne trompeuse quand le magasin n'a pas NocoDB. La modale d'attente ne se ferme jamais si le webhook ne répond pas. Les erreurs s'affichent en JSON brut anglais (« 403: {"message":...} »). Les confirmations utilisent window.confirm, alors qu'Avoirs et DLC utilisent AlertDialog. Le chargement est un simple texte « Chargement... » en pleine page. La pagination revient à la page 1 après chaque validation. La modale de détail est éditable même depuis « Voir les détails » et duplique la gestion des commentaires. Les règles d'accès se contredisent : le message de la page parle des managers, le menu latéral ne cite qu'admin et directeur, la barre mobile inclut les managers.
|
||||
|
||||
### `/payment-schedule`
|
||||
|
||||
**Rôle :** Afficher, pour le magasin sélectionné, les factures fournisseurs à payer dans le mois choisi (date d'échéance, fournisseur, mode de paiement, montants HT et TTC), avec totaux par mode de paiement et export CSV pour Excel.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Choisir un mois et voir les échéances
|
||||
- Lire le total HT/TTC du mois et par mode de paiement
|
||||
- Exporter les échéances (filtrées par mode de paiement, colonnes HT/TTC) en CSV
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/payment-schedule?groupId= | au montage et à chaque changement de magasin (staleTime par défaut 30 s, donc rappelée à chaque retour sur la page après 30 s) | server/routes.ts:390 -> storage.getUser + getUserGroups (directeur) + getGroup + storage.getDeliveries() SANS filtre (tous magasins, storage.ts:885) + boucles séquentielles verificationService.verifyInvoice + storage.updateDelivery + storage.getSuppliers() | Endpoint le plus lent du périmètre. Il charge toutes les livraisons de tous les magasins avec leurs relations, filtre en JS, puis appelle NocoDB et écrit en base un par un (await dans des boucles for) au sein d'une requête GET. Le repli TTC écrit dans la mauvaise colonne, donc la boucle recommence à chaque chargement. |
|
||||
| POST /api/payment-schedule/export | au clic « Exporter » dans la modale | server/routes.ts:549 -> storage.getDeliveries() complet + storage.getSuppliers() + filtrage mois et mode de paiement en JS + génération CSV | Même chargement complet que le GET. Champs CSV non échappés. Logs verbeux (paramètres, utilisateur). |
|
||||
|
||||
**Lisibilité / simplicité :** Page plutôt claire et plus simple que le rapprochement, mais hors charte. En-tête h1 text-3xl sans bandeau blanc, alors qu'Avoirs, Commandes et Rapprochement utilisent h2 text-xl/2xl dans un bandeau. Icône dollar pour des euros. La carte « Période » répète le mois déjà affiché dans le sélecteur. Les montants s'affichent en « 1234.50 € » au lieu de « 1 234,50 € ». Le TTC vaut souvent « 0.00 € » à cause d'un bug serveur. On change de mois uniquement par une liste de 25 mois, sans flèches précédent/suivant. Le filtre par mode de paiement n'existe que dans l'export. Le vocabulaire mélange CSV et Excel. Un spinner plein écran remplace aussi l'en-tête à chaque changement de magasin. Les erreurs 403 (manager arrivé par URL) donnent une carte générique. La ligne d'en-tête (titre + bouton + sélecteur de 256 px) n'est pas responsive alors que la route est servie telle quelle sur mobile.
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [ECHE-01](#eche-01) | haute | perf-serveur | M | GET /api/payment-schedule charge toutes les livraisons de tous les magasins puis appelle NocoDB et écrit en base, une livraison à la fois | `server/routes.ts:430` |
|
||||
| [ECHE-02](#eche-02) | haute | bug | S | Le repli TTC écrit le montant HT au lieu du TTC : boucle infinie à chaque chargement et TTC affiché à 0,00 € | `server/routes.ts:501` |
|
||||
| [RAPPRO-01](#rappro-01) | haute | perf-api | M | La liste des livraisons transporte la fiche magasin complète (logo base64, SMTP, NocoDB) et toutes les livraisons de tous les statuts | `server/storage.ts:912` |
|
||||
| [RAPPRO-02](#rappro-02) | haute | perf-api | M | Avalanche de vérifications automatiques (POST verify-invoice + PUT redondant) à chaque ouverture de la page | `client/src/pages/BLReconciliation.tsx:386` |
|
||||
| [RAPPRO-03](#rappro-03) | haute | bug | S | Les commentaires renvoient la ligne users complète (hash du mot de passe) et la fiche magasin complète | `server/storage.ts:3047` |
|
||||
| [RAPPRO-04](#rappro-04) | haute | ux-simplicite | S | La modale « Traitement en cours » ne peut pas être fermée et aucun timeout n'existe (client ni serveur) | `client/src/pages/BLReconciliation.tsx:1684` |
|
||||
| [ECHE-03](#eche-03) | moyenne | perf-serveur | S | L'export recharge toutes les livraisons de tous les magasins et produit un CSV non échappé | `server/routes.ts:596` |
|
||||
| [ECHE-04](#eche-04) | moyenne | coherence-design | S | En-tête hors charte, non responsive, icône dollar et carte « Période » redondante | `client/src/pages/PaymentSchedulePage.tsx:295` |
|
||||
| [ECHE-05](#eche-05) | moyenne | lisibilite | S | Montants affichés au format anglais (« 1234.50 € ») au lieu du format français | `client/src/pages/PaymentSchedulePage.tsx:340` |
|
||||
| [NOCO-01](#noco-01) | moyenne | bug | S | Les erreurs techniques NocoDB (timeout, HTTP 5xx, réseau) sont mises en cache 12 à 24 h comme « facture introuvable » | `server/invoiceVerification.ts:488` |
|
||||
| [NOCO-02](#noco-02) | moyenne | bug | S | L'upsert du cache de vérification ne met à jour ni expiresAt ni invoiceAmountTTC : le cache « permanent » expire au bout de 6 h | `server/storage.ts:1746` |
|
||||
| [NOCO-03](#noco-03) | moyenne | perf-serveur | S | Clés de cache BL incohérentes, et un hit BL ne renvoie ni montant ni échéance | `server/invoiceVerification.ts:635` |
|
||||
| [RAPPRO-05](#rappro-05) | moyenne | perf-serveur | M | getDelivery() sert au contrôle d'accès mais lance 5 à 7 requêtes séquentielles | `server/storage.ts:1004` |
|
||||
| [RAPPRO-06](#rappro-06) | moyenne | perf-serveur | S | PUT /api/deliveries/:id charge toute la table fournisseurs pour en lire un seul | `server/routes.ts:2043` |
|
||||
| [RAPPRO-07](#rappro-07) | moyenne | bug | S | Changer la référence facture déclenche un appel NocoDB synchrone qui efface l'échéance saisie à la main | `server/routes.ts:1992` |
|
||||
| [RAPPRO-08](#rappro-08) | moyenne | perf-client | S | Rafraîchissements trop larges : refetchQueries sur toutes les requêtes livraisons, même inactives, et rechargement complet pour un compteur de commentaires | `client/src/pages/BLReconciliation.tsx:758` |
|
||||
| [RAPPRO-09](#rappro-09) | moyenne | perf-client | M | Calculs non mémoïsés en O(n×m) et re-rendu de toute la page chaque seconde ou à chaque résultat de vérification | `client/src/pages/BLReconciliation.tsx:456` |
|
||||
| [RAPPRO-10](#rappro-10) | moyenne | perf-api | S | Requête /api/suppliers redondante qui retarde la vérification automatique | `client/src/pages/BLReconciliation.tsx:78` |
|
||||
| [RAPPRO-11](#rappro-11) | moyenne | perf-client | S | staleTime 0, clé de cache isolée et écran « Chargement... » texte en pleine page | `client/src/pages/BLReconciliation.tsx:376` |
|
||||
| [RAPPRO-12](#rappro-12) | moyenne | bug | S | Les livraisons des fournisseurs en mode « automatique » validées depuis Livraisons/Calendrier n'apparaissent dans aucun onglet | `client/src/pages/BLReconciliation.tsx:456` |
|
||||
| [RAPPRO-13](#rappro-13) | moyenne | ux-simplicite | S | Bouton Valider grisé avec une consigne impossible à suivre quand le magasin n'a pas NocoDB | `client/src/pages/BLReconciliation.tsx:1258` |
|
||||
| [RAPPRO-14](#rappro-14) | moyenne | ux-simplicite | M | Jusqu'à 6 boutons-icônes par ligne, sans texte, et des coches de vérification sans explication | `client/src/pages/BLReconciliation.tsx:1169` |
|
||||
| [RAPPRO-15](#rappro-15) | moyenne | coherence-design | M | Les deux onglets n'affichent pas les mêmes colonnes ni le même calcul d'écart, et « Date Livr. » montre la date prévue | `client/src/pages/BLReconciliation.tsx:1424` |
|
||||
| [RAPPRO-16](#rappro-16) | moyenne | ux-simplicite | S | Messages d'erreur affichés en JSON brut et en anglais | `client/src/lib/queryClient.ts:4` |
|
||||
| [RAPPRO-17](#rappro-17) | moyenne | coherence-design | S | Règles d'accès contradictoires entre la page, les menus, les permissions et le serveur | `client/src/pages/BLReconciliation.tsx:31` |
|
||||
| [RAPPRO-19](#rappro-19) | moyenne | ux-simplicite | M | Sur mobile, la page desktop est servie telle quelle (tableau 900px, 10 colonnes, modale en 2 colonnes) | `client/src/components/RouterProduction.tsx:121` |
|
||||
| [RAPPRO-21](#rappro-21) | moyenne | bug | S | Le proxy d'envoi de facture appelle une URL fournie par le client et lit le corps sans limite de taille | `server/routes.ts:916` |
|
||||
| [RAPPRO-22](#rappro-22) | moyenne | perf-api | S | « Vérifier toutes les factures » force un appel NocoDB pour chaque ligne, y compris celles déjà vertes, sans suivi de progression | `client/src/pages/BLReconciliation.tsx:339` |
|
||||
| [ECHE-06](#eche-06) | basse | perf-client | S | Spinner plein écran à chaque changement de magasin, cache court sur un endpoint coûteux, erreurs génériques | `client/src/pages/PaymentSchedulePage.tsx:254` |
|
||||
| [ECHE-07](#eche-07) | basse | lisibilite | S | Vocabulaire CSV/Excel incohérent et filtre par mode de paiement réservé à l'export | `client/src/pages/PaymentSchedulePage.tsx:309` |
|
||||
| [NOCO-04](#noco-04) | basse | perf-serveur | S | Logs console volumineux sur les chemins chauds (vérification, liste et mise à jour des livraisons) | `server/invoiceVerification.ts:22` |
|
||||
| [RAPPRO-18](#rappro-18) | basse | lisibilite | S | En-tête redondant et vocabulaire technique (webhook, workflow, AUTO, abréviations) | `client/src/pages/BLReconciliation.tsx:911` |
|
||||
| [RAPPRO-20](#rappro-20) | basse | ux-simplicite | S | Retour en page 1 après chaque validation, et la validation rapide n'a pas d'état « en cours » | `client/src/components/ui/pagination.tsx:157` |
|
||||
| [RAPPRO-23](#rappro-23) | basse | dette-code | S | Code mort, imports inutilisés et logs de debug dans le rendu | `client/src/pages/BLReconciliation.tsx:1032` |
|
||||
| [RAPPRO-24](#rappro-24) | basse | bug | S | Hooks appelés après un return conditionnel (règle des hooks violée) | `client/src/pages/BLReconciliation.tsx:31` |
|
||||
| [RAPPRO-25](#rappro-25) | basse | bug | S | Le minuteur de la modale d'attente n'est pas nettoyé au démontage | `client/src/pages/BLReconciliation.tsx:527` |
|
||||
| [RAPPRO-26](#rappro-26) | basse | ux-simplicite | S | Modale de rapprochement surchargée : commentaires en double, « Voir les détails » qui ouvre une édition, titre et badge redondants | `client/src/components/modals/ReconciliationModal.tsx:147` |
|
||||
| [RAPPRO-27](#rappro-27) | basse | lisibilite | S | Commentaires signés par l'email, boutons sans libellé, confirmation native | `client/src/components/ReconciliationComments.tsx:287` |
|
||||
| [RAPPRO-28](#rappro-28) | basse | ux-simplicite | S | Envoi de facture : annuler le sélecteur de fichier affiche une erreur, et le libellé Facture/Avoir est trompeur | `client/src/pages/BLReconciliation.tsx:543` |
|
||||
| [RAPPRO-29](#rappro-29) | basse | perf-bundle | S | Pages Rapprochement et Échéancier importées dans le bundle initial | `client/src/components/RouterProduction.tsx:14` |
|
||||
|
||||
### ECHE-01
|
||||
|
||||
**GET /api/payment-schedule charge toutes les livraisons de tous les magasins puis appelle NocoDB et écrit en base, une livraison à la fois** — perf-serveur, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `server/routes.ts:430`
|
||||
- **Constat :** routes.ts:430-431 `const allDeliveries = await storage.getDeliveries();
|
||||
const groupDeliveries = allDeliveries.filter((d: any) => d.groupId === groupId && d.invoiceReference);`. 444-481 `for (const delivery of deliveriesWithoutDueDate) { ... await verificationService.verifyInvoice(...) ... await storage.updateDelivery(...)`. 491-516 deuxième boucle séquentielle identique pour le TTC. 519 `await storage.getSuppliers()` alors que delivery.supplier est déjà joint.
|
||||
- **Impact :** Le temps de réponse croît avec le nombre total de livraisons de l'enseigne et avec le nombre de factures sans échéance ou sans TTC. Si NocoDB est lent (timeout 10 s par appel, invoiceVerification.ts:535), le chargement peut durer plusieurs minutes et l'utilisateur reste face au spinner. Un GET qui écrit en base est aussi rejoué à chaque visite.
|
||||
- **Recommandation :** Remplacer par une requête SQL ciblée : SELECT d.id, d.invoice_reference, d.due_date, d.invoice_amount, d.invoice_amount_ttc, s.name, s.payment_method FROM deliveries d JOIN suppliers s ... WHERE d.group_id=$1 AND d.invoice_reference IS NOT NULL AND d.due_date IS NOT NULL (index idx_deliveries_due_date / (group_id, due_date)). Sortir le repli NocoDB du GET : il est déjà fait par verify-invoice qui persiste dueDate et TTC (routes.ts:2555-2587). Si on le garde, le passer en tâche de fond ou en Promise.all avec concurrence limitée.
|
||||
|
||||
### ECHE-02
|
||||
|
||||
**Le repli TTC écrit le montant HT au lieu du TTC : boucle infinie à chaque chargement et TTC affiché à 0,00 €** — bug, sévérité haute, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:501`
|
||||
- **Constat :** routes.ts:487 `const deliveriesNeedingTTC = allDeliveriesWithDueDate.filter((d: any) => !d.invoiceAmountTTC || parseFloat(d.invoiceAmountTTC) === 0);` puis 501-504 `const updates: any = { invoiceAmount: result.invoiceAmount.toString(), supplierName: result.supplierName };` : invoiceAmountTTC n'est jamais écrit et supplierName n'est pas une colonne de deliveries. 506 `updates.dueDate = new Date(result.dueDate)` sans normalizeDateString, contrairement à 457.
|
||||
- **Impact :** Les mêmes livraisons repartent dans la boucle NocoDB + UPDATE à chaque ouverture de l'échéancier (ECHE-01). La colonne « Montant TTC » et le total TTC du mois restent faux (0,00 €) pour ces factures, ce qui fausse la trésorerie affichée et exportée.
|
||||
- **Recommandation :** N'écrire invoiceAmountTTC que si result.invoiceAmountTTC > 0. Ne réécrire invoiceAmount que s'il est absent. Retirer supplierName et normaliser la date. Corriger NOCO-02 d'abord. Idéalement, sortir ce repli du GET (ECHE-01) ou mémoriser la tentative pour ne pas la relancer à chaque chargement. Côté client, afficher « — » quand le TTC vaut 0 ou est inconnu.
|
||||
|
||||
### RAPPRO-01
|
||||
|
||||
**La liste des livraisons transporte la fiche magasin complète (logo base64, SMTP, NocoDB) et toutes les livraisons de tous les statuts** — perf-api, sévérité haute, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/storage.ts:912`
|
||||
- **Constat :** storage.ts:912-913 `supplier: suppliers, group: groups,` (groups contient `logo: text("logo") // Logo en data URI (data:image/png;base64,...)` schema.ts:66, smtpHost/smtpUser, webhookUrl, mapping NocoDB) ; storage.ts:598-599 loadOrdersByIds rejoint encore `supplier: suppliers, group: groups` dans chaque commande. routes.ts:1781/1859 `deliveries = await storage.getDeliveries(groupIds);` sans filtre de statut ni pagination ; filtrage client BLReconciliation.tsx:371 `deliveries.filter((d: any) => d.status === 'delivered')`. Aucun middleware compression (server/index.ts:30). Chaque réponse est clonée récursivement par stripSmtpPassword (routes.ts:168-174, sanitize.ts:13-44).
|
||||
- **Impact :** Si un logo est configuré, chaque ligne transporte ce logo une à deux fois. Avec 1 000 livraisons, cela fait des dizaines de Mo de JSON non compressé à télécharger, parser et cloner côté serveur. Le chargement initial de la page est lent et la mémoire du navigateur gonfle. Les livraisons « planned », inutiles ici, sont aussi transférées.
|
||||
- **Recommandation :** Étape 1, sans risque : dans getDeliveries, getDeliveriesByDateRange et loadOrdersByIds, remplacer `group: groups` par une projection {id, name, color, nocodbConfigId, nocodbTableName, webhookUrl}. Ce sont les seuls champs group lus par le client (name, webhookUrl, color, nocodbTableName, nocodbConfigId) et le serveur ne lit que group.name. Ajouter compression() (nouvelle dépendance) dans index.ts et index.production.ts. Étape 2 : créer l'endpoint dédié /api/reconciliation/deliveries (WHERE status='delivered', pagination, totaux par onglet). Ne retirer webhookUrl de la projection qu'après RAPPRO-21, une fois que le serveur retrouve lui-même l'URL du webhook.
|
||||
|
||||
### RAPPRO-02
|
||||
|
||||
**Avalanche de vérifications automatiques (POST verify-invoice + PUT redondant) à chaque ouverture de la page** — perf-api, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:386`
|
||||
- **Constat :** BLReconciliation.tsx:396-436 parcourt TOUTES les livraisons livrées (les deux onglets, y compris celles qui ne s'affichent nulle part) et appelle `handleVerifyInvoice(delivery, false, true)`. Puis onSuccess (151-186) envoie `apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)`, alors que le serveur a déjà enregistré ces données (routes.ts:2555-2587 `await storage.updateDelivery(deliveryId, updateData)`). autoRequestedIdsRef (124) est un useRef, donc remis à zéro à chaque montage, et verificationResults est un état local perdu à la navigation.
|
||||
- **Impact :** Pour 200 livraisons sans montant : 400 requêtes HTTP à chaque visite. Côté serveur, environ 10 requêtes SQL séquentielles par vérification (voir RAPPRO-05) plus NocoDB. Les coches arrivent lentement, la page re-rend à chaque résultat, puis un refetch complet de la liste suit quand la file se vide (269-270).
|
||||
- **Recommandation :** En supprimant le PUT, conserver `needsCacheInvalidationRef.current = true` dans onSuccess quand result.exists apporte des données (montant, échéance ou référence). Sinon la liste ne se rafraîchit plus après l'auto-remplissage fait côté serveur.
|
||||
|
||||
### RAPPRO-03
|
||||
|
||||
**Les commentaires renvoient la ligne users complète (hash du mot de passe) et la fiche magasin complète** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:3047`
|
||||
- **Constat :** storage.ts:3047-3048 `author: users,
|
||||
group: groups,` dans getReconciliationComments (idem getReconciliationCommentById 3101-3102). La table users contient `password: varchar("password")` (schema.ts:39). sanitize.ts:36 ne retire que `smtpPassword`/`smtp_password`. Le client n'utilise que `comment.author.email` (ReconciliationComments.tsx:287).
|
||||
- **Impact :** Fuite du hash de mot de passe de chaque auteur de commentaire vers tout utilisateur qui ouvre la modale. La réponse est aussi alourdie par le logo du magasin et une copie de la livraison répétés pour chaque commentaire.
|
||||
- **Recommandation :** Projeter author {id,email,firstName,lastName,username,role}, retirer group et delivery ainsi que le .map qui les remballe, dans les deux fonctions (3035 et 3088), et alléger le type ReconciliationCommentWithRelations en conséquence. Dans sanitize.ts, supprimer simplement la clé `password`. Aucune autre colonne password n'existe dans le schéma.
|
||||
|
||||
### RAPPRO-04
|
||||
|
||||
**La modale « Traitement en cours » ne peut pas être fermée et aucun timeout n'existe (client ni serveur)** — ux-simplicite, sévérité haute, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:1684`
|
||||
- **Constat :** BLReconciliation.tsx:1684 `<Dialog open={showWaitingModal} onOpenChange={() => {}}>`, sans bouton Fermer. 588-592 `fetch('/api/reconciliation/send-invoice', {...})` sans AbortController, donc la branche `error.name === 'AbortError'` (633) est du code mort. Côté serveur, routes.ts:940-943 `await fetch(parts.webhookUrl, { method: 'POST', body: formData })` sans signal ni timeout. Le texte affiche « 60s restantes (max) » puis « Finalisation... » indéfiniment (1720).
|
||||
- **Impact :** Si le webhook n8n/Make ne répond pas, l'écran reste bloqué : l'utilisateur doit recharger la page et ne sait pas si la facture est partie.
|
||||
- **Recommandation :** Fixer le délai serveur un peu au-dessus de la durée annoncée (par exemple AbortSignal.timeout(90 000)) et le délai client au-dessus du délai serveur (par exemple 100 s). En cas de dépassement, afficher : « Le traitement prend plus de temps que prévu ; il peut encore aboutir. Vérifiez la ligne avant de renvoyer la facture. » Ajouter un bouton « Continuer en arrière-plan » qui ferme la modale sans annuler la requête et affiche un toast à la fin.
|
||||
|
||||
### ECHE-03
|
||||
|
||||
**L'export recharge toutes les livraisons de tous les magasins et produit un CSV non échappé** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:596`
|
||||
- **Constat :** routes.ts:596-601 `const allDeliveries = await storage.getDeliveries();
|
||||
const groupDeliveries = allDeliveries.filter((d: any) => d.groupId === validatedGroupId && d.invoiceReference && d.dueDate);`, puis filtre du mois en JS (609-616) et `await storage.getSuppliers()` (619). Lignes CSV 654-662 `row.join(';')` sans guillemets.
|
||||
- **Impact :** L'export est lent sur une base volumineuse. Un nom de fournisseur ou une référence contenant « ; » ou un retour à la ligne décale les colonnes dans Excel.
|
||||
- **Recommandation :** Requête SQL filtrée sur group_id et due_date BETWEEN début/fin du mois, avec jointure suppliers. Échapper chaque champ (`"${v.replace(/"/g,'""')}"`). Réutiliser la même fonction que le GET.
|
||||
|
||||
### ECHE-04
|
||||
|
||||
**En-tête hors charte, non responsive, icône dollar et carte « Période » redondante** — coherence-design, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/PaymentSchedulePage.tsx:295`
|
||||
- **Constat :** PaymentSchedulePage.tsx:295-297 `<div className="flex justify-between items-center">` + `<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>`, alors qu'Avoirs, Orders et BLReconciliation utilisent un bandeau `bg-white border-b` + `h2 text-xl sm:text-2xl` avec icône. 311 `<div className="w-64">` sur la même ligne, sans flex-col mobile, alors que la route est servie sur mobile (RouterProduction.tsx:129). 333 `<DollarSign .../>` pour des euros. 382-395 carte « Période » qui répète le mois déjà dans le sélecteur. Libellé du menu « Échéance » (Sidebar.tsx:266) différent du titre.
|
||||
- **Impact :** L'utilisateur ne retrouve pas la mise en page des autres modules. Sur téléphone, l'en-tête déborde. Le symbole $ prête à confusion.
|
||||
- **Recommandation :** Reprendre l'en-tête standard (bandeau blanc, h2, icône CreditCard), `flex-col sm:flex-row`, icône Euro, remplacer la carte Période par une carte utile (prochaine échéance, ou montant restant à payer cette semaine). Harmoniser le libellé du menu en « Échéancier ». Ajouter des flèches mois précédent/suivant autour du sélecteur.
|
||||
|
||||
### ECHE-05
|
||||
|
||||
**Montants affichés au format anglais (« 1234.50 € ») au lieu du format français** — lisibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/PaymentSchedulePage.tsx:340`
|
||||
- **Constat :** PaymentSchedulePage.tsx:340 `{monthTotal.toFixed(2)} €`, idem 346, 368, 373, 448, 451. BLReconciliation.tsx:1071 `${parseFloat(delivery.blAmount).toFixed(2)}€`, idem 1123, 1157, 1471, 1479.
|
||||
- **Impact :** Les grands montants sont difficiles à lire (pas de séparateur de milliers) et le point décimal ne correspond pas aux habitudes françaises ni à Excel FR.
|
||||
- **Recommandation :** Créer un utilitaire partagé `formatEuro = (n) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n)` et l'utiliser dans les deux pages, avec des colonnes de montants alignées à droite et `tabular-nums`.
|
||||
|
||||
### NOCO-01
|
||||
|
||||
**Les erreurs techniques NocoDB (timeout, HTTP 5xx, réseau) sont mises en cache 12 à 24 h comme « facture introuvable »** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/invoiceVerification.ts:488`
|
||||
- **Constat :** invoiceVerification.ts:394-403 `if (matchResult.error) { ... await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled);` avec exists:false, donc expiration +12 h (106-108). 488-489, même chose dans le catch. Pour les BL : 762-786 erreur mise en cache `expiresAt.setHours(expiresAt.getHours() + 24)`. Côté client, exists === false grise le bouton Valider (BLReconciliation.tsx:1248-1257).
|
||||
- **Impact :** Une micro-coupure NocoDB affiche une croix rouge et bloque la validation pendant 12 à 24 h pour toutes les factures vérifiées à ce moment. Seul le bouton « Vérifier toutes » (forceRefresh) contourne le problème, ce que l'utilisateur ne peut pas deviner.
|
||||
- **Recommandation :** Ne pas mettre en cache les résultats avec matchResult.error, ou seulement 2 à 5 minutes. Renvoyer un statut distinct (`status: 'error'`) et l'afficher côté UI par une icône orange « Service de vérification indisponible — réessayer » plutôt qu'une croix rouge.
|
||||
|
||||
### NOCO-02
|
||||
|
||||
**L'upsert du cache de vérification ne met à jour ni expiresAt ni invoiceAmountTTC : le cache « permanent » expire au bout de 6 h** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:1746`
|
||||
- **Constat :** storage.ts:1746-1760 `onConflictDoUpdate({ target: invoiceVerificationCache.cacheKey, set: { exists, matchType, errorMessage, supplierName, invoiceReference, invoiceAmount, dueDate, isReconciled, cacheHit, apiCallTime, updatedAt } })` : ni expiresAt ni invoiceAmountTTC. updateCacheAsReconciled (invoiceVerification.ts:186-195) calcule `expiresAt + 50 ans` mais l'upsert l'ignore. getInvoiceVerificationCache supprime la ligne une fois expirée (storage.ts:1729-1732).
|
||||
- **Impact :** Les factures validées sont re-interrogées dans NocoDB après 6 h au lieu d'être servies depuis le cache, d'où des appels externes inutiles et des pages plus lentes. Un forceRefresh ne rallonge pas non plus la durée de vie, et le TTC reste périmé dans le cache.
|
||||
- **Recommandation :** Ajouter `expiresAt: cacheData.expiresAt, invoiceAmountTTC: cacheData.invoiceAmountTTC, groupId: cacheData.groupId` au `set` de onConflictDoUpdate.
|
||||
|
||||
### NOCO-03
|
||||
|
||||
**Clés de cache BL incohérentes, et un hit BL ne renvoie ni montant ni échéance** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/invoiceVerification.ts:635`
|
||||
- **Constat :** verifyInvoiceByBL : `const cacheKey = `bl_${groupId}_${blNumber.trim().toLowerCase()}_${supplierName.toLowerCase()}`;` (635), alors que updateCacheAsReconciled(blNumber) utilise generateCacheKey = `${groupId}_${ref}` (13, appelé routes.ts:2653 et invoiceVerification.ts:232), donc la clé BL n'est jamais marquée réconciliée. Le hit cache BL (641-651) ne renvoie que exists/matchType/invoiceReference/supplierName, et la sauvegarde succès (827-839) ne stocke ni invoiceAmount ni dueDate.
|
||||
- **Impact :** Le cache BL ne devient jamais permanent (rappels NocoDB toutes les 24 h). Un hit BL donne une coche verte sans montant ni échéance, ce qui laisse des cellules « Non renseigné » et relance d'autres vérifications.
|
||||
- **Recommandation :** Centraliser la construction de clé (buildKey(type, groupId, value, supplier)) et l'utiliser partout. Stocker et renvoyer invoiceAmount, invoiceAmountTTC et dueDate dans le cache BL comme pour la référence facture.
|
||||
|
||||
### RAPPRO-05
|
||||
|
||||
**getDelivery() sert au contrôle d'accès mais lance 5 à 7 requêtes séquentielles** — perf-serveur, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/storage.ts:1004`
|
||||
- **Constat :** storage.ts:1005-1034 jointure, 1041 `await this.getUser(delivery.createdBy)`, 1060 `await this.getOrder(delivery.orderId)` (qui fait lui-même une jointure commande + une jointure de toutes ses livraisons, storage.ts:816-856), 1079-1082 count des commentaires. getDelivery est appelé pour le seul contrôle d'accès dans verify-invoice (routes.ts:2474), PUT (1914), DELETE (2144), commentaires (2183, 2219), send-supplier-mail (2338), toujours après getUserWithGroups qui fait 2 requêtes séquentielles (storage.ts:372-387).
|
||||
- **Impact :** Chaque vérification ou modification coûte 7 à 9 allers-retours SQL avant même le travail utile. Multiplié par les centaines de vérifications automatiques (RAPPRO-02), cela sature le pool Postgres et ralentit toutes les pages.
|
||||
- **Recommandation :** Gain immédiat sans changer le format : dans getDelivery, lancer getUser, getOrder et le count en Promise.all après la jointure principale. Pour getDeliveryForAccess, inclure id, groupId, supplierId, status, reconciled, blNumber, invoiceReference, scheduledDate, deliveredDate, supplier {id,name,email,automaticReconciliation} et group {id,name}, et ne l'utiliser que dans les routes qui ne lisent rien d'autre : verify-invoice, PUT, DELETE et commentaires. Garder getDelivery pour GET /api/deliveries/:id. Lancer getUserWithGroups et le chargement de la livraison en parallèle.
|
||||
|
||||
### RAPPRO-06
|
||||
|
||||
**PUT /api/deliveries/:id charge toute la table fournisseurs pour en lire un seul** — perf-serveur, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:2043`
|
||||
- **Constat :** routes.ts:2040-2044 `if (data.status === 'delivered' || data.blNumber) { ... const suppliers = await storage.getSuppliers();
|
||||
const supplier = suppliers.find((s: any) => s.id === updatedDelivery.supplierId);`. La modale envoie toujours blNumber s'il est rempli (ReconciliationModal.tsx:86), et delivery.supplier est déjà chargé par getDelivery (storage.ts:1028).
|
||||
- **Impact :** Chaque enregistrement depuis la modale lit inutilement toute la table fournisseurs.
|
||||
- **Recommandation :** `const supplier = (data.supplierId === undefined || data.supplierId === delivery.supplierId) ? delivery.supplier : (await storage.getSuppliers()).find((s: any) => s.id === updatedDelivery.supplierId);`
|
||||
|
||||
### RAPPRO-07
|
||||
|
||||
**Changer la référence facture déclenche un appel NocoDB synchrone qui efface l'échéance saisie à la main** — bug, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:1992`
|
||||
- **Constat :** routes.ts:1992-2030 : si `data.invoiceReference !== delivery.invoiceReference`, appel `verificationService.verifyInvoice(data.invoiceReference, delivery.groupId, true, ...)` (forceRefresh, jusqu'à 2 × 10 s de timeout), puis `data.dueDate = null;` si NocoDB ne trouve pas l'échéance (2017) ou en cas d'erreur (2023). Cela écrase la dueDate envoyée par la modale (ReconciliationModal.tsx:90).
|
||||
- **Impact :** L'utilisateur saisit référence et échéance, clique Enregistrer, attend parfois plus de 10 s (« Enregistrement... »), puis découvre que son échéance a disparu. Perte de donnée et lenteur perçue.
|
||||
- **Recommandation :** Ne respecter la saisie que si l'utilisateur a modifié l'échéance, c'est-à-dire si data.dueDate diffère de delivery.dueDate. Sinon, compléter depuis NocoDB. Ne jamais remplacer par null en cas d'échec ou de résultat vide : conserver la valeur existante. Le cas 2027 (référence vidée, donc échéance vidée) peut rester. Faire l'appel NocoDB après la réponse HTTP ou le laisser à verify-invoice.
|
||||
|
||||
### RAPPRO-08
|
||||
|
||||
**Rafraîchissements trop larges : refetchQueries sur toutes les requêtes livraisons, même inactives, et rechargement complet pour un compteur de commentaires** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:758`
|
||||
- **Constat :** BLReconciliation.tsx:758-759 `queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] }); queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });` (idem 481-482, 797-798, 837-838). En TanStack 5.83, refetchQueries fait `this.#queryCache.findAll(filters)` sans filtre `type` (node_modules/@tanstack/query-core/build/modern/queryClient.js:172), donc les requêtes inactives (mois du calendrier visités, etc.) sont aussi rechargées. ReconciliationModal.tsx:67-69 invalide, puis onSave (BLReconciliation.tsx:481-482) refait un refetch. ReconciliationComments.tsx:62-63, 88-89, 113-114 invalident toute la liste pour un compteur.
|
||||
- **Impact :** Une validation ou un commentaire peut déclencher plusieurs téléchargements de la liste complète (lourde, voir RAPPRO-01), y compris pour des pages non affichées. L'interface rame juste après chaque action.
|
||||
- **Recommandation :** Après validation, suppression ou commentaire : `queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], ...)` (mise à jour locale, compteur +1/-1), puis une seule `invalidateQueries({ queryKey: ['/api/deliveries/bl', selectedStoreId] })` (refetchType 'active' par défaut). Remplacer les refetchQueries(['/api/deliveries']) par invalidateQueries. Supprimer le refetch en double de handleSaveReconciliation.
|
||||
|
||||
### RAPPRO-09
|
||||
|
||||
**Calculs non mémoïsés en O(n×m) et re-rendu de toute la page chaque seconde ou à chaque résultat de vérification** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:456`
|
||||
- **Constat :** BLReconciliation.tsx:456-461 `deliveriesWithBL.filter(... suppliers.find(s => s.id === delivery.supplierId) ...)` recalculé à chaque rendu, de même que filterDeliveries (851-866) et `suppliers.find` dans chaque ligne validée (1422). startProcessingTimer (527-537) fait `setProcessingSeconds(prev => prev + 1)` chaque seconde dans le composant page. Chaque vérification fait 2 setState (144-147, 189-193).
|
||||
- **Impact :** Pendant l'envoi d'une facture, la page entière (10 colonnes × N lignes, filtres complets) est recalculée chaque seconde. Pendant la vérification auto de 200 lignes, il y a plus de 400 rendus complets, ce qui rend la saisie et le scroll saccadés.
|
||||
- **Recommandation :** useMemo pour manualNotValidatedDeliveries, allValidatedDeliveries et les listes filtrées, avec une Map supplierById ou directement delivery.supplier.automaticReconciliation. Extraire la ligne dans un composant React.memo qui reçoit son seul résultat de vérification. Déplacer le compteur de secondes dans un composant WaitingDialog isolé. Ajouter useDeferredValue sur searchTerm.
|
||||
|
||||
### RAPPRO-10
|
||||
|
||||
**Requête /api/suppliers redondante qui retarde la vérification automatique** — perf-api, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:78`
|
||||
- **Constat :** BLReconciliation.tsx:78-80 `useQuery<any[]>({ queryKey: ['/api/suppliers'] })` sert à `supplier?.automaticReconciliation` (457, 1423) et à l'email de repli (681). Ces champs sont déjà dans `delivery.supplier` (storage.ts:912 `supplier: suppliers`). L'effet 387 `if (!deliveriesWithBL.length || !suppliers.length) return;` attend suppliers alors que le corps de l'effet ne l'utilise pas.
|
||||
- **Impact :** Une requête inutile, et un enchaînement en cascade (waterfall) : les coches de vérification n'apparaissent qu'après le chargement des fournisseurs. Si la liste fournisseurs est vide ou en erreur, aucune vérification automatique n'a lieu.
|
||||
- **Recommandation :** Supprimer la requête suppliers, utiliser `delivery.supplier?.automaticReconciliation` et `delivery.supplier?.email`, retirer `suppliers` des dépendances et de la garde de l'effet.
|
||||
|
||||
### RAPPRO-11
|
||||
|
||||
**staleTime 0, clé de cache isolée et écran « Chargement... » texte en pleine page** — perf-client, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:376`
|
||||
- **Constat :** BLReconciliation.tsx:350 `queryKey: ['/api/deliveries/bl', selectedStoreId]` alors que l'URL appelée est `/api/deliveries` (357), donc aucun partage avec les autres pages. 376 `staleTime: 0 // Éviter la mise en cache`. 892-894 `if (isLoading) { return <div className="flex justify-center items-center h-64">Chargement...</div>; }`.
|
||||
- **Impact :** Chaque retour sur la page retélécharge la liste complète (lourde). Pendant ce temps, l'utilisateur voit un texte gris sans structure. Un changement de magasin vide tout l'écran.
|
||||
- **Recommandation :** Renommer d'abord la clé en ['/api/deliveries', 'reconciliation', selectedStoreId] pour que toutes les invalidations de ['/api/deliveries'] la touchent par préfixe. Mettre à jour en même temps les setQueryData de BLReconciliation et le prédicat de ValidateDeliveryModal. Passer ensuite à staleTime 60 s avec placeholderData: keepPreviousData, et afficher un squelette sous l'en-tête et la recherche.
|
||||
|
||||
### RAPPRO-12
|
||||
|
||||
**Les livraisons des fournisseurs en mode « automatique » validées depuis Livraisons/Calendrier n'apparaissent dans aucun onglet** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:456`
|
||||
- **Constat :** Onglet manuel : `const isManual = supplier?.automaticReconciliation !== true;` (458). Onglet validé : `delivery.reconciled === true` (464). Or POST /api/deliveries/:id/validate (routes.ts:2643 `await storage.validateDelivery(id, blData);`, storage.ts:1179-1194) ne met jamais reconciled=true. Seul le PUT générique le fait (routes.ts:2040-2056).
|
||||
- **Impact :** Des factures de fournisseurs « automatiques » peuvent n'être ni rapprochées ni visibles. Elles échappent au contrôle, alors que la vérification automatique les traite en arrière-plan.
|
||||
- **Recommandation :** Appliquer la même auto-réconciliation dans /validate (si supplier.automaticReconciliation et blNumber), ou ajouter un filtre ou onglet « Automatiques en attente » pour les rendre visibles.
|
||||
|
||||
### RAPPRO-13
|
||||
|
||||
**Bouton Valider grisé avec une consigne impossible à suivre quand le magasin n'a pas NocoDB** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:1258`
|
||||
- **Constat :** BLReconciliation.tsx:1258-1267 bouton désactivé avec `title="Validation impossible : veuillez d'abord vérifier la facture en cliquant sur l'icône de recherche"`. Or l'icône de recherche n'est affichée que si `delivery.group?.nocodbTableName || nocodbConfigId || webhookUrl` (1085), et handleVerifyInvoice sort en silence sans config (298-307).
|
||||
- **Impact :** Dans un magasin sans NocoDB, l'utilisateur ne peut valider une ligne qu'en devinant qu'il doit saisir un montant facture dans la modale. Il est bloqué et appelle le support.
|
||||
- **Recommandation :** Sans configuration NocoDB, ou si un montant facture est saisi, autoriser la validation manuelle avec une confirmation (« Valider sans vérification automatique ? »). Rédiger un tooltip exact selon le cas (pas de référence, pas de NocoDB, facture introuvable).
|
||||
|
||||
### RAPPRO-14
|
||||
|
||||
**Jusqu'à 6 boutons-icônes par ligne, sans texte, et des coches de vérification sans explication** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:1169`
|
||||
- **Constat :** Cellule Actions 1169-1316 : Mail, Upload, MessageSquare, Check, Edit, Trash2, en `h-8 w-8 p-0` sans libellé, expliqués seulement par `title=`. Coches 1089-1094 `<CheckCircle className="h-4 w-4 text-green-500 cursor-help" />` / `<XCircle ... cursor-help />` sans title ni Tooltip, alors que `errorMessage` est stocké (202) mais jamais affiché.
|
||||
- **Impact :** Un employé non technicien ne sait pas quel bouton faire en premier ni pourquoi une facture est en rouge. Les title ne s'affichent pas au toucher (tablette, mobile).
|
||||
- **Recommandation :** Garder une seule action principale visible avec du texte (« Valider » en vert, ou « Compléter » si des données manquent) et regrouper le reste (Relancer le fournisseur, Envoyer la facture, Commentaires, Modifier, Supprimer) dans un menu « ⋯ » (DropdownMenu) avec libellés. Ajouter un Tooltip/Popover sur la coche qui affiche errorMessage en français et un bouton « Revérifier ».
|
||||
|
||||
### RAPPRO-15
|
||||
|
||||
**Les deux onglets n'affichent pas les mêmes colonnes ni le même calcul d'écart, et « Date Livr. » montre la date prévue** — coherence-design, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:1424`
|
||||
- **Constat :** Onglet manuel, Écart en € et rouge au-delà de 10 € : 1150-1157 `diffAbs > 10 ? 'text-red-600'` ... `{diff.toFixed(2)}€`. Onglet validé, Écart en % et rouge au-delà de 5 % : 1424-1425 `(... / parseFloat(delivery.blAmount) * 100).toFixed(1)` et 1498 `Math.abs(parseFloat(ecart)) > 5 ? "destructive"`. Ordre des colonnes : manuel « Montant BL, Ref. Facture » (1004-1009), validé « Ref. Facture, Montant BL » (1397-1402). « Date Livr. » affiche `delivery.scheduledDate` (1065, 1458) alors que le tri utilise deliveredDate (373). Formats dd/MM/yy et dd/MM/yyyy mélangés. « Non renseigné » et « Non renseignée » mélangés (1452/1464). Styles de boutons différents (Button outline 1176 contre <button opacity-70> 1519).
|
||||
- **Impact :** L'utilisateur compare deux tableaux qui ne se lisent pas de la même façon. Un écart de 8 € apparaît orange d'un côté et peut apparaître rouge en % de l'autre. La date affichée n'est pas celle de la livraison réelle.
|
||||
- **Recommandation :** Factoriser une définition de colonnes et un composant <ReconciliationRow> commun aux deux onglets. Fixer une seule règle d'écart (montant € + % en sous-texte, mêmes seuils). Renommer en « Livrée le » avec deliveredDate (repli scheduledDate) et utiliser dd/MM/yyyy partout.
|
||||
|
||||
### RAPPRO-16
|
||||
|
||||
**Messages d'erreur affichés en JSON brut et en anglais** — ux-simplicite, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/lib/queryClient.ts:4`
|
||||
- **Constat :** queryClient.ts:4-7 `const text = (await res.text()) || res.statusText; throw new Error(`${res.status}: ${text}`);`. Les toasts affichent error.message tel quel : ReconciliationComments.tsx:95 `description: error?.message || ...`, BLReconciliation.tsx:702, ReconciliationModal.tsx:76. Messages serveur en anglais : routes.ts:2270 "Only comment author or admin can edit comments", 2598 "Failed to verify invoice".
|
||||
- **Impact :** Un manager voit par exemple « 403: {"message":"Only comment author or admin can edit comments"} ». Le message est incompréhensible et anxiogène.
|
||||
- **Recommandation :** Garder le message actuel et ajouter à l'Error les propriétés `status` et `userMessage` (body.message || body.error). Les toasts passent par un helper getUserErrorMessage(error). Adapter isUnauthorizedError et les tests de retry pour lire error.status en priorité, avec repli sur l'ancien test. Traduire ensuite les messages serveur et masquer Modifier/Supprimer pour les non-auteurs.
|
||||
|
||||
### RAPPRO-17
|
||||
|
||||
**Règles d'accès contradictoires entre la page, les menus, les permissions et le serveur** — coherence-design, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:31`
|
||||
- **Constat :** La page bloque seulement `user?.role === 'employee'` avec le texte « Seuls les managers et administrateurs peuvent accéder » (31-42). Sidebar.tsx:259-262 `roles: ["admin", "directeur"]`, PhoneBottomNav.tsx:37 `roles: ["admin", "directeur", "manager"]`, shared/permissions.ts:53-57 `manager: []`. Le bouton Upload s'affiche sans contrôle de rôle (shouldShowInvoiceButton 653-672), alors que le serveur refuse les managers (routes.ts:891 `user.role !== 'admin' && user.role !== 'directeur'`).
|
||||
- **Impact :** Un manager arrive sur la page depuis la barre mobile, voit des boutons, envoie un PDF, attend, puis obtient « Accès refusé ». Le message d'accès restreint est lui-même faux.
|
||||
- **Recommandation :** Garder la page avec `permissions.canView('reconciliation')`, aligner Sidebar et PhoneBottomNav sur la même matrice, masquer Upload/Valider/Supprimer selon canCreate/canValidate/canDelete, et corriger le texte (« réservé aux directeurs et administrateurs »).
|
||||
|
||||
### RAPPRO-19
|
||||
|
||||
**Sur mobile, la page desktop est servie telle quelle (tableau 900px, 10 colonnes, modale en 2 colonnes)** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:121`
|
||||
- **Constat :** RouterProduction.tsx:121 `<Route path="/bl-reconciliation" component={BLReconciliation} />` dans le bloc mobile (« Pages sans version mobile »). BLReconciliation.tsx:992 `<table className="w-full min-w-[900px]">`, colonnes `px-6`. ReconciliationModal.tsx:120 et 162 `grid grid-cols-2 gap-4` sans breakpoint.
|
||||
- **Impact :** Sur téléphone (accessible aux rôles listés dans PhoneBottomNav), il faut faire défiler horizontalement pour atteindre les actions. Les champs de la modale font la moitié de l'écran.
|
||||
- **Recommandation :** Sous md, rendu en cartes (fournisseur + montants + statut + bouton principal) comme les pages mobiles existantes. `grid-cols-1 sm:grid-cols-2` dans la modale.
|
||||
|
||||
### RAPPRO-21
|
||||
|
||||
**Le proxy d'envoi de facture appelle une URL fournie par le client et lit le corps sans limite de taille** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:916`
|
||||
- **Constat :** Client BLReconciliation.tsx:582 `formData.append('webhookUrl', selectedDeliveryForInvoice.group.webhookUrl);`. Serveur routes.ts:907-918, lecture complète `req.on('data', (chunk: Buffer) => chunks.push(chunk));` sans plafond, puis 940 `await fetch(parts.webhookUrl, ...)` sans vérifier que l'URL est celle du magasin.
|
||||
- **Impact :** Un compte directeur peut faire appeler n'importe quelle URL interne par le serveur (SSRF). Un gros fichier peut saturer la mémoire du serveur et ralentir toute l'application.
|
||||
- **Recommandation :** Envoyer deliveryId et retrouver group.webhookUrl côté serveur après contrôle d'accès. Plafonner la taille (par exemple 15 Mo, multer ou busboy avec limits), contrôler le type PDF côté client et serveur, et ajouter un timeout (voir RAPPRO-04).
|
||||
|
||||
### RAPPRO-22
|
||||
|
||||
**« Vérifier toutes les factures » force un appel NocoDB pour chaque ligne, y compris celles déjà vertes, sans suivi de progression** — perf-api, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:339`
|
||||
- **Constat :** BLReconciliation.tsx:322-340 filtre toutes les lignes de manualNotValidatedDeliveries ayant une référence, puis `handleVerifyInvoice(delivery, true); // Force refresh pour toutes`. Le seul retour est le toast initial « Vérification de N facture(s)/BL en cours... » (342-345). Aucun toast de fin.
|
||||
- **Impact :** Contournement du cache : 1 à 2 requêtes NocoDB par ligne (invoiceVerification.ts:386-429), donc des minutes d'attente sur un gros magasin. L'utilisateur ne sait pas quand c'est fini.
|
||||
- **Recommandation :** Forcer le rafraîchissement seulement pour les lignes rouges ou non vérifiées. Afficher une progression (« 12 / 40 vérifiées ») et un toast final récapitulatif (x trouvées, y introuvables, z erreurs).
|
||||
|
||||
### ECHE-06
|
||||
|
||||
**Spinner plein écran à chaque changement de magasin, cache court sur un endpoint coûteux, erreurs génériques** — perf-client, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/PaymentSchedulePage.tsx:254`
|
||||
- **Constat :** PaymentSchedulePage.tsx:69-81 useQuery sans staleTime (30 s par défaut, queryClient.ts:76) ni placeholderData. 254-260 `if (isLoading) { return (<div ...><Loader2 .../></div>); }` remplace aussi l'en-tête. 262-275 carte « Une erreur est survenue » identique pour un 403 (manager arrivé par URL, routes.ts:398-399). 278-289 « Configuration requise » affiché pour le message serveur « Groupe non trouvé » (routes.ts:424).
|
||||
- **Impact :** Chaque retour sur la page après 30 s relance l'endpoint lent (ECHE-01). Changer de magasin fait disparaître toute la page. Les messages d'erreur n'aident pas l'utilisateur.
|
||||
- **Recommandation :** staleTime 5 min, `placeholderData: keepPreviousData`, squelette des cartes et du tableau sous l'en-tête, message spécifique pour 403 (« Réservé aux directeurs et administrateurs ») et pour un magasin introuvable.
|
||||
|
||||
### ECHE-07
|
||||
|
||||
**Vocabulaire CSV/Excel incohérent et filtre par mode de paiement réservé à l'export** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/PaymentSchedulePage.tsx:309`
|
||||
- **Constat :** Bouton « Exporter CSV » (309), modale « Exporter vers CSV ... (ouvrez le fichier dans Excel) » (468-471), toast d'erreur « l'export du fichier Excel » (230). Les modes de paiement ne se filtrent que dans la modale d'export (477-509) : le tableau (425-454) n'a ni filtre ni recherche fournisseur.
|
||||
- **Impact :** Les utilisateurs ne savent pas quel format ils obtiennent et ne peuvent pas afficher à l'écran seulement les virements ou les traites qu'ils vont payer.
|
||||
- **Recommandation :** Libellé unique « Exporter pour Excel ». Ajouter au-dessus du tableau des puces de filtre par mode de paiement (réutilisées par défaut dans l'export) et une recherche fournisseur.
|
||||
|
||||
### NOCO-04
|
||||
|
||||
**Logs console volumineux sur les chemins chauds (vérification, liste et mise à jour des livraisons)** — perf-serveur, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/invoiceVerification.ts:22`
|
||||
- **Constat :** invoiceVerification.ts:22-31, 111-119, 140-148, 261, 273, 276 `console.log('✅ [INVOICE] Résultat depuis cache:', cachedResult)`, 294-299 : environ 8 logs d'objets par vérification. storage.ts:1718-1722 et 1736 à chaque lecture de cache, storage.ts:935 à chaque liste. routes.ts:1762, 1868 (GET /api/deliveries), 1932 `console.log('🔄 Updating delivery:', { id, data: req.body ...})`, 2033 `console.log('✅ Delivery updated successfully:', { id, updatedDelivery })`, 2516-2523, 2552.
|
||||
- **Impact :** Pendant les centaines de vérifications automatiques, les sorties console synchrones ralentissent l'event loop Node et remplissent les logs Docker, ce qui rend les vraies erreurs difficiles à trouver.
|
||||
- **Recommandation :** Commencer, sans risque, par réduire les logs qui sérialisent des objets complets (routes.ts:1932 et 2033, invoiceVerification.ts:273 et 2552) à un identifiant et quelques champs. Introduire ensuite un logger avec LOG_LEVEL, défaut 'info' en production, et passer les logs [CACHE]/[INVOICE] en debug, après accord de l'exploitant.
|
||||
|
||||
### RAPPRO-18
|
||||
|
||||
**En-tête redondant et vocabulaire technique (webhook, workflow, AUTO, abréviations)** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:911`
|
||||
- **Constat :** Compteurs en double : badges 911-916 « {n} à traiter » / « {n} validées » et badges d'onglets 935-944. Sous-titre « Gestion des rapprochements manuels et automatiques » (907), onglet « Rapprochement Manuel » (934). En-têtes abrégés « Date Livr. », « Ref. Facture », « Montant Fact. » (1002-1012). Badge « AUTO » (1445). URL affichée `Envoi via: {selectedDeliveryForInvoice.group.webhookUrl}` (1662-1666), « Le workflow peut prendre jusqu'à 1 minute » (1706), toast « Facture traitée avec succès via le webhook » (603). Encadré vert permanent dans l'onglet Validées (1342-1357).
|
||||
- **Impact :** Le bruit visuel et le jargon informatique ralentissent la compréhension. Exposer l'URL du webhook n'apporte rien à l'utilisateur.
|
||||
- **Recommandation :** Onglets « À traiter (n) » / « Validées (n) » et suppression des badges d'en-tête. En-têtes complets : « Livrée le », « N° facture », « Montant facture ». « AUTO » remplacé par « Validé automatiquement ». Supprimer l'URL et dire « Envoi au service de traitement des factures… ». Remplacer l'encadré par une ligne d'aide discrète ou un tooltip.
|
||||
|
||||
### RAPPRO-20
|
||||
|
||||
**Retour en page 1 après chaque validation, et la validation rapide n'a pas d'état « en cours »** — 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]);`. Valider une ligne la retire de la liste et renvoie donc l'utilisateur en page 1. BLReconciliation.tsx:735-769 handleQuickValidate est une fonction async sans état pending : le bouton (1239-1247) reste cliquable pendant la requête.
|
||||
- **Impact :** En traitant la page 3, chaque validation renvoie en page 1, ce qui fait perdre le fil. Un double clic envoie deux PUT et affiche deux toasts.
|
||||
- **Recommandation :** Ajouter à usePagination un paramètre optionnel resetKey (par exemple `${searchTerm}|${activeTab}`). Quand il est fourni, remettre la page à 1 sur ce changement plutôt que sur data.length, et garder le comportement actuel par défaut pour les 7 autres appelants. Le bornage à totalPages existe déjà (166-170).
|
||||
|
||||
### RAPPRO-23
|
||||
|
||||
**Code mort, imports inutilisés et logs de debug dans le rendu** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:1032`
|
||||
- **Constat :** Imports inutilisés : Card, CardContent, CardHeader, CardTitle (7), Settings, AlertTriangle, X, Filter (15), Select... (17), Textarea (19). Branches mortes de l'onglet manuel, filtré sur `reconciled !== true` (459) : style `delivery.reconciled === true ? 'bg-gray-100 opacity-60'` (1032-1035) et bloc dévalidation (1290-1315). console.log dans le rendu de chaque ligne (662-669, 1429-1435). console.log non conditionné (624). ReconciliationModal.tsx:57,59 `console.log('Sending update for delivery:'...)`. formData.reconciled jamais utilisé (ReconciliationModal.tsx:38,50). PaymentSchedulePage.tsx:6-7 imports Building et startOfMonth inutilisés.
|
||||
- **Impact :** Le fichier de 1 787 lignes est plus difficile à maintenir, la console est polluée en production (624, ReconciliationModal), et des appels de log tournent pendant le rendu en dev.
|
||||
- **Recommandation :** Supprimer les imports inutilisés, les branches mortes, les logs de rendu et les console.log non conditionnés. Retirer reconciled de l'état de la modale.
|
||||
|
||||
### RAPPRO-24
|
||||
|
||||
**Hooks appelés après un return conditionnel (règle des hooks violée)** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:31`
|
||||
- **Constat :** BLReconciliation.tsx:31-49 `if (user?.role === 'employee') { return (...); }` placé avant `const [activeTab, setActiveTab] = useState("manual");` (51) et tous les useQuery/useMutation/useEffect qui suivent.
|
||||
- **Impact :** Si le rôle passe de undefined à « employee » pendant la vie du composant (rechargement de session), React lève « Rendered fewer hooks than expected » et la page plante.
|
||||
- **Recommandation :** Extraire le contenu dans <BLReconciliationContent/> et garder dans BLReconciliation le seul test de rôle qui rend soit le message, soit le contenu.
|
||||
|
||||
### RAPPRO-25
|
||||
|
||||
**Le minuteur de la modale d'attente n'est pas nettoyé au démontage** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:527`
|
||||
- **Constat :** BLReconciliation.tsx:529 `const interval = setInterval(...)` stocké dans un state (538 `setProcessingTimeout(interval)`), nettoyé seulement dans handleCloseWaitingModal par `clearTimeout(processingTimeout)` (522). Aucun useEffect de nettoyage au démontage.
|
||||
- **Impact :** Si l'utilisateur quitte la page pendant l'envoi, l'intervalle continue d'appeler setState sur un composant démonté (fuite mineure).
|
||||
- **Recommandation :** Stocker l'intervalle dans un useRef, utiliser clearInterval et nettoyer dans un useEffect de démontage. Ou isoler le minuteur dans le composant de la modale (voir RAPPRO-09).
|
||||
|
||||
### RAPPRO-26
|
||||
|
||||
**Modale de rapprochement surchargée : commentaires en double, « Voir les détails » qui ouvre une édition, titre et badge redondants** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/modals/ReconciliationModal.tsx:147`
|
||||
- **Constat :** ReconciliationModal.tsx:147-157 onglet « Commentaires », en doublon de la modale commentaires dédiée de la page (BLReconciliation.tsx:1744-1785). 102-112 titre « Rapprochement Automatique/Manuel » suivi d'un badge « AUTO/MANUEL ». 136-139 statut « Rapproché / En attente » alors que la page dit « Validées ». 242-248 bouton principal Enregistrer en `variant="outline"`. L'icône Eye « Voir les détails » de l'onglet validé (BLReconciliation.tsx:1582-1588) ouvre ce même formulaire éditable.
|
||||
- **Impact :** Deux chemins pour la même action, un vocabulaire qui change d'un écran à l'autre, et un risque de modifier par erreur une ligne déjà validée en croyant seulement la consulter.
|
||||
- **Recommandation :** Retirer l'onglet Commentaires de la modale (ou retirer la modale commentaires séparée), garder un titre simple « Facture de {fournisseur} », utiliser « Validé / À traiter », mettre Enregistrer en bouton plein, et ouvrir les lignes validées en lecture seule avec un bouton « Modifier » explicite.
|
||||
|
||||
### RAPPRO-27
|
||||
|
||||
**Commentaires signés par l'email, boutons sans libellé, confirmation native** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/ReconciliationComments.tsx:287`
|
||||
- **Constat :** ReconciliationComments.tsx:287 `Par {comment.author.email} • ...`. Boutons 292-307 `<Edit2 className="w-3 h-3" />` et `<Trash2 className="w-3 h-3" />` sans title ni aria-label, affichés pour tous les commentaires. 152 `if (confirm("Êtes-vous sûr ..."))`. Même usage de window.confirm dans BLReconciliation.tsx:781 et 818, alors qu'Avoirs.tsx et DlcPage.tsx utilisent AlertDialog.
|
||||
- **Impact :** L'auteur est peu lisible (email au lieu du nom), les icônes de 12 px sont difficiles à viser, et les confirmations n'ont pas le même style que le reste de l'application.
|
||||
- **Recommandation :** Afficher « Prénom Nom » (repli username), ajouter aria-label et title, n'afficher Modifier/Supprimer que pour l'auteur ou l'admin, et utiliser un composant ConfirmDialog commun basé sur AlertDialog.
|
||||
|
||||
### RAPPRO-28
|
||||
|
||||
**Envoi de facture : annuler le sélecteur de fichier affiche une erreur, et le libellé Facture/Avoir est trompeur** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/BLReconciliation.tsx:543`
|
||||
- **Constat :** BLReconciliation.tsx:543-551 `if (file && file.type === 'application/pdf') {...} else { toast({ title: "Erreur", description: "Veuillez sélectionner un fichier PDF" ...` : déclenché aussi quand l'utilisateur annule (file undefined). Le bouton dit `title="Envoyer Facture/Avoir"` (1212), la modale « Envoyer Facture » (1629), et le type envoyé est toujours `formData.append('type', 'Facture')` (585).
|
||||
- **Impact :** Un toast d'erreur rouge apparaît alors que l'utilisateur n'a rien fait de mal, et il pense pouvoir envoyer un avoir alors que ce n'est pas possible.
|
||||
- **Recommandation :** Ne rien faire si aucun fichier n'est choisi. Soit proposer un choix Facture/Avoir dans la modale, soit renommer le bouton « Envoyer la facture (PDF) ». Utiliser une zone de dépôt avec le nom et la taille du fichier.
|
||||
|
||||
### RAPPRO-29
|
||||
|
||||
**Pages Rapprochement et Échéancier importées dans le bundle initial** — perf-bundle, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:14`
|
||||
- **Constat :** RouterProduction.tsx:14 `import BLReconciliation from "@/pages/BLReconciliation";` et 29 `import PaymentSchedulePage from "@/pages/PaymentSchedulePage";` en imports statiques, sans React.lazy dans le routeur.
|
||||
- **Impact :** Les 1 787 lignes du rapprochement, ses modales et date-fns/locale sont téléchargés par tous les utilisateurs (y compris les employés qui n'y ont pas accès) avant le premier affichage.
|
||||
- **Recommandation :** `const BLReconciliation = lazy(() => import("@/pages/BLReconciliation"))` (idem PaymentSchedulePage) avec <Suspense fallback={<PageSkeleton/>}> autour du Switch.
|
||||
@@ -0,0 +1,476 @@
|
||||
# Avoirs
|
||||
|
||||
_41 constats vérifiés — 8 haute, 21 moyenne, 12 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/avoirs (bureau)`
|
||||
|
||||
**Rôle :** Suivre les demandes d'avoir fournisseurs d'un magasin : créer une demande, la faire passer de 'En attente de demande' à 'Demandé' puis à 'Reçu', vérifier que la facture d'avoir existe en comptabilité (NocoDB), valider l'avoir, envoyer le PDF au système externe (webhook) et consulter l'historique des avoirs finalisés.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Créer une demande d'avoir (fournisseur, magasin, référence, montant, commentaire)
|
||||
- Changer le statut d'un avoir (En attente de demande, Demandé, Reçu)
|
||||
- Vérifier la présence de la facture d'avoir en comptabilité (loupe ou bouton 'Vérifier toutes')
|
||||
- Valider ou dévalider un avoir (admin/directeur)
|
||||
- Envoyer le PDF de l'avoir reçu
|
||||
- Modifier ou supprimer un avoir (admin/directeur)
|
||||
- Rechercher un avoir et consulter les onglets En cours / Finalisés
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/user | au montage (useQuery ['/api/user']) | server/localAuth.production.ts:246 (lit req.user en session, sans requête BD) | En production, ce cache react-query n'est jamais rempli par useAuthUnified (enabled: isDevelopment, useAuthUnified.ts:43). La requête est donc refaite, et toutes les autres requêtes de la page l'attendent (enabled: !!user). |
|
||||
| GET /api/groups | au montage, après /api/user ; puis queryClient.fetchQuery à l'envoi du PDF | server/routes.ts:970 → storage.getUserWithGroups (storage.ts:371, 2 requêtes) + storage.getGroups (storage.ts:432, select * avec le logo base64) pour admin ; sinon userGroups[].group (id, name, color seulement) | N'est utile qu'aux formulaires création/édition. Pour un non-admin, la réponse ne contient pas webhookUrl, ce qui casse l'envoi du PDF. |
|
||||
| GET /api/suppliers | au montage, après /api/user | server/routes.ts:1141 → storage.getUser + storage.getSuppliers | Liste complète des fournisseurs, utilisée seulement dans les modales. |
|
||||
| GET /api/avoirs?storeId=X | au montage (après /api/user), puis refetch après chaque création, modification ou suppression (invalidation par prédicat) | server/routes.ts:3711 → storage.getUserWithGroups + storage.getAvoirs (storage.ts:2528 : 3 LEFT JOIN, groups.* complet, aucune limite) | queryKey [avoirsUrl, selectedStoreId, role] différente de celle du mobile. Charge tout l'historique, y compris le logo du magasin répété dans chaque ligne. |
|
||||
| POST /api/avoirs/:id/verify-invoice | boucle séquentielle dans un useEffect à chaque changement de 'avoirs' (montage et chaque refetch) ; clic sur la loupe ; 'Vérifier toutes' (forceRefresh, toutes les 200 ms) ; 1 s après l'envoi du PDF | server/routes.ts:3982 → getUserWithGroups + storage.getAvoir (storage.ts:2575) + invoiceVerificationService.verifyInvoice (invoiceVerification.ts:249 → getInvoiceVerificationCache, getGroup, getActiveNocodbConfig, appel HTTP NocoDB, saveInvoiceVerificationCache) | N appels séquentiels à chaque affichage. Si le cache a expiré (6 h ou 12 h), chaque appel va jusqu'à NocoDB. |
|
||||
| POST /api/avoirs | clic 'Créer' | server/routes.ts:3774 → getUserWithGroups, createAvoir (storage.ts:2619), getGroup, fetch webhook attendu sans timeout, updateAvoirWebhookStatus | Réponse bloquée tant que le webhook externe n'a pas répondu. supplierName codé en dur à 'Unknown'. |
|
||||
| PUT /api/avoirs/:id | modale Modifier ; changement de statut dans le menu de la ligne ; remplissage automatique du montant après une vérification réussie | server/routes.ts:3851 → getUserWithGroups, getAvoir, updateAvoir (storage.ts:2624), puis getGroup et webhook si passage à 'Reçu' | 3 logs JSON complets par appel. Les champs non envoyés sont remis à null. |
|
||||
| DELETE /api/avoirs/:id | confirmation de suppression | server/routes.ts:3945 → getUserWithGroups, getAvoir, deleteAvoir (storage.ts:2633) | Réservé à admin et directeur. |
|
||||
| PUT /api/avoirs/:id/nocodb-verification | clic Valider / Dévalider | server/routes.ts:4083 → getUserWithGroups, updateAvoirNocodbVerification (storage.ts:2644), getAvoir, updateCacheAsReconciled | Réservé à admin et directeur, mais le bouton est affiché à tous les rôles. L'invalidation côté client ne correspond pas à la queryKey quand un magasin est sélectionné. |
|
||||
| POST /api/cache/mark-reconciled | juste après un Valider réussi | server/routes.ts:4126 → invoiceVerificationService.updateCacheAsReconciled | Redondant : la route nocodb-verification marque déjà le cache comme réconcilié. |
|
||||
| POST {group.webhookUrl} (URL externe) | clic 'Envoyer' dans la modale d'envoi du PDF | aucun : appel direct depuis le navigateur | Dépend du CORS du service externe. La page BLReconciliation passe, elle, par le proxy /api/reconciliation/send-invoice (routes.ts:883). |
|
||||
|
||||
**Lisibilité / simplicité :** La page est dense et ambiguë pour un utilisateur non technicien. Le tableau a 8 colonnes. Chaque ligne contient un menu de statut modifiable directement, sans confirmation, alors qu'un passage à 'Reçu' déclenche un webhook externe, et la même ligne affiche parfois 'Lecture seule'. La colonne Actions aligne jusqu'à 6 boutons-icônes de couleurs différentes, sans texte (seulement un title au survol), et la colonne Vérification se réduit à une loupe de 16 px. Le badge vert 'Validé' apparaît dès que la facture est trouvée, alors que l'avoir n'est pas validé et que le bouton 'Valider' est toujours proposé, ce qui rend le flux principal contradictoire. Tout l'onglet Finalisés est grisé (opacity-75), donc peu lisible. Les compteurs sont affichés trois fois (en-tête, onglets, carte). Plusieurs messages emploient du jargon (NocoDB, webhook) ou sont en anglais ('Failed to create avoir', '403: {"message":...}'). Formats peu soignés : '#Sans référence', '12.50 €' avec un point. Le formulaire de création n'est pas réinitialisé après envoi et ignore le magasin sélectionné en haut. Le PDF ne peut pas être envoyé par un directeur ou un employé. Côté performance, chaque affichage déclenche une cascade (/api/user, puis 3 requêtes, puis N POST de vérification séquentiels). 'Vérifier toutes' produit O(N²) requêtes et N toasts. De nombreux console.log de debug restent actifs en production.
|
||||
|
||||
### `/avoirs (mobile)`
|
||||
|
||||
**Rôle :** Version téléphone du suivi des avoirs : voir la liste des avoirs du magasin sélectionné, en créer un rapidement et changer leur statut ou les supprimer.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Consulter les avoirs du magasin sélectionné
|
||||
- Rechercher par fournisseur ou référence de facture
|
||||
- Créer une demande d'avoir (feuille du bas)
|
||||
- Changer le statut via le menu '⋮'
|
||||
- Supprimer un avoir
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/user | au montage, via useAuthUnified dans la page et dans MobileLayout (fetch direct no-cache en production) | server/localAuth.production.ts:246 | 2 requêtes supplémentaires à chaque ouverture de la page. La liste attend l'une d'elles (enabled: !!user). |
|
||||
| GET /api/avoirs?storeId=X | au montage, seulement si un magasin est sélectionné et l'utilisateur chargé | server/routes.ts:3711 → getUserWithGroups + storage.getAvoirs (storage.ts:2528) | Avec 'Tous les magasins', la requête n'est pas lancée et la page affiche 'Aucun avoir trouvé'. queryKey ['/api/avoirs', storeId] différente de celle du bureau. |
|
||||
| GET /api/suppliers | au montage | server/routes.ts:1141 → storage.getUser + storage.getSuppliers | Utile seulement quand la feuille de création est ouverte. |
|
||||
| GET /api/groups | au montage | server/routes.ts:970 → getUserWithGroups (+ getGroups pour admin) | Même clé que MobileApp, donc dédoublonnée, mais inutile : StoreContext fournit déjà 'stores'. |
|
||||
| POST /api/avoirs | clic 'Créer la demande' | server/routes.ts:3774 → createAvoir + webhook synchrone | Montant envoyé à 0 par défaut. |
|
||||
| PUT /api/avoirs/:id | menu '⋮' > Marquer En attente / Demandé / Reçu | server/routes.ts:3851 → getAvoir, updateAvoir, webhook si 'Reçu' | Envoie l'objet avoir complet, relations comprises (group avec le logo base64). Aucun onError. |
|
||||
| DELETE /api/avoirs/:id | menu '⋮' > Supprimer (sans confirmation) | server/routes.ts:3945 → deleteAvoir | Erreur 403 pour manager et employé, avalée sans message. |
|
||||
|
||||
**Lisibilité / simplicité :** La présentation en cartes est plus lisible que le tableau du bureau. En revanche : la liste est vide avec le message trompeur 'Aucun avoir trouvé' quand 'Tous les magasins' est sélectionné. La suppression se fait d'un tap, sans confirmation, et est proposée à tous les rôles. Les échecs (403) sont silencieux. Le menu de statut propose toujours les 3 états, sans indiquer l'état courant et sans les restrictions du bureau (manager vers 'Reçu', avoirs validés). Le formulaire n'affiche aucune erreur de validation et force le montant à 0 €. Les libellés, couleurs et statuts diffèrent du bureau ('Suivi' contre 'Gestion', indigo contre bleu, 'En attente' contre 'En attente de demande', 'Vérifié Compta' contre 'Validé'). Il n'y a pas de séparation En cours / Finalisés, la date s'affiche sans l'année, et le chargement utilise un spinner alors que le bureau utilise un skeleton.
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [AVOIRS-01](#avoirs-01) | haute | perf-api | M | Boucle séquentielle de N appels POST verify-invoice à chaque chargement de la liste | `client/src/pages/Avoirs.tsx:192` |
|
||||
| [AVOIRS-02](#avoirs-02) | haute | perf-api | M | « Vérifier toutes » déclenche une cascade O(N²) de requêtes, N toasts et ferme la modale d'édition | `client/src/pages/Avoirs.tsx:689` |
|
||||
| [AVOIRS-03](#avoirs-03) | haute | bug | S | Après Valider ou Dévalider, la liste n'est pas rafraîchie quand un magasin est sélectionné | `client/src/pages/Avoirs.tsx:528` |
|
||||
| [AVOIRS-04](#avoirs-04) | haute | bug | M | Envoi du PDF impossible pour directeur et employé, et appel direct au webhook externe depuis le navigateur | `client/src/pages/Avoirs.tsx:776` |
|
||||
| [AVOIRS-05](#avoirs-05) | haute | ux-simplicite | S | Badge « Validé » affiché alors que l'avoir n'est pas validé (la facture a seulement été trouvée) | `client/src/pages/Avoirs.tsx:1037` |
|
||||
| [AVOIRS-API-01](#avoirs-api-01) | haute | perf-api | S | La liste renvoie la ligne complète du magasin (logo base64, SMTP, webhook) dans chaque avoir | `server/storage.ts:2546` |
|
||||
| [MOB-AVOIRS-01](#mob-avoirs-01) | haute | bug | S | Liste vide « Aucun avoir trouvé » quand « Tous les magasins » est sélectionné | `client/src/pages/mobile/AvoirsPage.tsx:83` |
|
||||
| [MOB-AVOIRS-02](#mob-avoirs-02) | haute | ux-simplicite | S | Suppression sans confirmation, proposée à tous les rôles, avec échec silencieux | `client/src/pages/mobile/AvoirsPage.tsx:257` |
|
||||
| [AVOIRS-06](#avoirs-06) | moyenne | ux-simplicite | M | Statut modifié immédiatement par un menu déroulant dans chaque ligne, sans confirmation, avec effet externe | `client/src/pages/Avoirs.tsx:994` |
|
||||
| [AVOIRS-07](#avoirs-07) | moyenne | ux-simplicite | M | Jusqu'à 6 boutons-icônes sans texte par ligne et une loupe de 16 px | `client/src/pages/Avoirs.tsx:1060` |
|
||||
| [AVOIRS-08](#avoirs-08) | moyenne | lisibilite | S | Onglet « Finalisés » entièrement grisé et semi-transparent | `client/src/pages/Avoirs.tsx:989` |
|
||||
| [AVOIRS-09](#avoirs-09) | moyenne | lisibilite | S | Messages techniques (NocoDB, webhook) et erreurs brutes en anglais affichés à l'utilisateur | `client/src/pages/Avoirs.tsx:666` |
|
||||
| [AVOIRS-10](#avoirs-10) | moyenne | bug | S | Bouton « Valider » proposé à des rôles que le serveur refuse | `client/src/pages/Avoirs.tsx:1076` |
|
||||
| [AVOIRS-11](#avoirs-11) | moyenne | perf-client | S | Requête /api/user redondante qui bloque toutes les autres (cascade) | `client/src/pages/Avoirs.tsx:147` |
|
||||
| [AVOIRS-12](#avoirs-12) | moyenne | bug | S | Formulaire de création non réinitialisé, et magasin par défaut qui ignore le magasin sélectionné | `client/src/pages/Avoirs.tsx:263` |
|
||||
| [AVOIRS-13](#avoirs-13) | moyenne | perf-client | S | Filtrages non mémoïsés et code mort exécuté à chaque frappe, avec un risque de plantage | `client/src/pages/Avoirs.tsx:871` |
|
||||
| [AVOIRS-14](#avoirs-14) | moyenne | perf-client | M | Aucune pagination : tout l'historique est rendu avec un Select Radix par ligne | `client/src/pages/Avoirs.tsx:988` |
|
||||
| [AVOIRS-15](#avoirs-15) | moyenne | dette-code | S | console.log de débogage actifs en production dans les chemins chauds | `client/src/pages/Avoirs.tsx:168` |
|
||||
| [AVOIRS-API-02](#avoirs-api-02) | moyenne | perf-api | M | Ni pagination ni filtre de statut en SQL : la séparation En cours / Finalisés se fait en JavaScript | `server/storage.ts:2561` |
|
||||
| [AVOIRS-API-03](#avoirs-api-03) | moyenne | perf-serveur | S | Logs JSON volumineux à chaque création, modification et vérification | `server/routes.ts:3874` |
|
||||
| [AVOIRS-API-04](#avoirs-api-04) | moyenne | perf-api | S | Création et modification bloquées par l'appel au webhook externe, sans timeout | `server/routes.ts:3821` |
|
||||
| [AVOIRS-API-05](#avoirs-api-05) | moyenne | bug | S | Le webhook avoir est envoyé avec un nom de fournisseur factice | `server/routes.ts:3813` |
|
||||
| [AVOIRS-API-06](#avoirs-api-06) | moyenne | bug | S | Le PUT efface montant, référence et commentaire s'ils ne sont pas envoyés, ce qui oblige à renvoyer tout l'objet | `server/routes.ts:3879` |
|
||||
| [AVOIRS-API-07](#avoirs-api-07) | moyenne | bug | S | Règles métier appliquées seulement côté client (statut « Reçu », avoirs validés, changement de magasin) | `server/routes.ts:3866` |
|
||||
| [MOB-AVOIRS-03](#mob-avoirs-03) | moyenne | ux-simplicite | S | Menu de statut sans indication de l'état courant ni restrictions | `client/src/pages/mobile/AvoirsPage.tsx:239` |
|
||||
| [MOB-AVOIRS-04](#mob-avoirs-04) | moyenne | perf-api | S | Le changement de statut envoie l'objet avoir complet avec ses relations | `client/src/pages/mobile/AvoirsPage.tsx:241` |
|
||||
| [MOB-AVOIRS-05](#mob-avoirs-05) | moyenne | bug | S | Montant forcé à 0 € lors d'une création sur mobile | `client/src/pages/mobile/AvoirsPage.tsx:135` |
|
||||
| [MOB-AVOIRS-06](#mob-avoirs-06) | moyenne | ux-simplicite | S | Erreurs de validation invisibles dans le formulaire mobile | `client/src/pages/mobile/AvoirsPage.tsx:46` |
|
||||
| [MOB-AVOIRS-07](#mob-avoirs-07) | moyenne | perf-client | M | Requêtes /api/user répétées et cascade avant le chargement des avoirs | `client/src/pages/mobile/AvoirsPage.tsx:72` |
|
||||
| [AVOIRS-16](#avoirs-16) | basse | ux-simplicite | S | Compteurs affichés trois fois et deux composants Tabs séparés | `client/src/pages/Avoirs.tsx:1168` |
|
||||
| [AVOIRS-17](#avoirs-17) | basse | lisibilite | S | Formats incohérents : « #Sans référence », montants « 12.50 € », couleur CSS invalide | `client/src/pages/Avoirs.tsx:1012` |
|
||||
| [AVOIRS-18](#avoirs-18) | basse | bug | S | La règle « un admin peut modifier un avoir validé » est inaccessible depuis le menu de statut | `client/src/pages/Avoirs.tsx:997` |
|
||||
| [AVOIRS-19](#avoirs-19) | basse | perf-api | S | Double marquage du cache lors de la validation, et validation hors useMutation | `client/src/pages/Avoirs.tsx:510` |
|
||||
| [AVOIRS-20](#avoirs-20) | basse | bug | S | Timer du modal d'attente jamais nettoyé, et modal fermable pendant l'envoi | `client/src/pages/Avoirs.tsx:746` |
|
||||
| [AVOIRS-21](#avoirs-21) | basse | dette-code | S | Code mort, imports inutilisés et typage faible | `client/src/pages/Avoirs.tsx:882` |
|
||||
| [AVOIRS-22](#avoirs-22) | basse | perf-bundle | S | Pages Avoirs (bureau et mobile) chargées dans le bundle initial | `client/src/components/RouterProduction.tsx:26` |
|
||||
| [AVOIRS-API-08](#avoirs-api-08) | basse | bug | S | Périmètre du directeur incohérent d'une route avoirs à l'autre | `server/routes.ts:3721` |
|
||||
| [AVOIRS-API-09](#avoirs-api-09) | basse | perf-serveur | S | verify-invoice recharge des données déjà disponibles et ne met pas en cache les résultats négatifs | `server/invoiceVerification.ts:285` |
|
||||
| [MOB-AVOIRS-08](#mob-avoirs-08) | basse | perf-client | S | Données de référence chargées inutilement et tri redondant | `client/src/pages/mobile/AvoirsPage.tsx:91` |
|
||||
| [MOB-AVOIRS-09](#mob-avoirs-09) | basse | coherence-design | M | Libellés, couleurs et statuts différents de la version bureau | `client/src/pages/mobile/AvoirsPage.tsx:180` |
|
||||
| [MOB-AVOIRS-10](#mob-avoirs-10) | basse | dette-code | S | Imports inutilisés, typage any et schéma dupliqué | `client/src/pages/mobile/AvoirsPage.tsx:19` |
|
||||
|
||||
### AVOIRS-01
|
||||
|
||||
**Boucle séquentielle de N appels POST verify-invoice à chaque chargement de la liste** — perf-api, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:192`
|
||||
- **Constat :** useEffect(() => { const loadCachedVerifications = async () => { ... for (const avoir of avoirs) { if (avoir.invoiceReference?.trim()) { const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', { invoiceReference: avoir.invoiceReference, forceRefresh: false }); ... } } }; loadCachedVerifications(); }, [avoirs]); (l.192-245). Le commentaire de la l.716 ('✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript') est contredit : la boucle est toujours active. Les avoirs déjà validés (nocodbVerified) ne sont pas exclus. Côté serveur, chaque appel exécute getUserWithGroups (2 requêtes), getAvoir (3 JOIN) et une lecture du cache. Le cache expire au bout de 6 h ou 12 h (invoiceVerification.ts:101-108), après quoi l'appel va jusqu'à NocoDB. Les magasins sans configuration ne sont jamais mis en cache (invoiceVerification.ts:302-309).
|
||||
- **Impact :** Avec 100 avoirs ayant une référence : 100 allers-retours successifs (plusieurs secondes à plusieurs dizaines de secondes de trafic) après chaque affichage. Chaque refetch (après toute création, modification ou suppression) relance la boucle entière. Les badges 'Validé' et les boutons 'Valider' apparaissent au compte-gouttes. Le serveur est chargé et le quota NocoDB consommé le matin, à l'expiration du cache.
|
||||
- **Recommandation :** Supprimer ce useEffect. Renvoyer l'état de vérification avec la liste : dans storage.getAvoirs, ajouter un LEFT JOIN invoice_verification_cache ON cache_key = avoirs.group_id || '_' || lower(trim(avoirs.invoice_reference)) AND expires_at > now(), exposé sous la forme verification {exists, invoiceAmount, isReconciled}. À défaut, créer un endpoint groupé POST /api/avoirs/verifications {ids} qui lit uniquement le cache (un seul SELECT ... WHERE cache_key IN (...)) sans jamais appeler NocoDB, consommé par un useQuery. Exclure les avoirs nocodbVerified.
|
||||
|
||||
### AVOIRS-02
|
||||
|
||||
**« Vérifier toutes » déclenche une cascade O(N²) de requêtes, N toasts et ferme la modale d'édition** — perf-api, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:689`
|
||||
- **Constat :** avoirsToVerify.forEach((avoir, index) => { setTimeout(() => { handleVerifyAvoirInvoice(avoir, true); }, index * 200); }); (l.703-708). forceRefresh=true contourne le cache, d'où un appel NocoDB par avoir, y compris pour les avoirs déjà validés (le filtre des l.690-693 n'exclut pas nocodbVerified). En cas de succès : editAvoirMutation.mutate({ id: variables.avoirId, data: { ..., amount: result.invoiceAmount, ... } }) (l.601-612). Son onSuccess invalide toutes les clés '/api/avoirs' (l.303-307), affiche le toast 'Avoir modifié' et exécute setIsEditDialogOpen(false); setSelectedAvoir(null) (l.308-313). Le refetch produit un nouveau tableau 'avoirs', ce qui relance la boucle d'AVOIRS-01 (N POST).
|
||||
- **Impact :** Pour N avoirs : N appels NocoDB, N PUT, N GET /api/avoirs et jusqu'à N×N POST verify-invoice. L'utilisateur reçoit N toasts 'Avoir modifié'. Si une modale d'édition est ouverte, elle se ferme toute seule. Le montant saisi est remplacé sans prévenir par le montant HT de la facture.
|
||||
- **Recommandation :** Créer un endpoint serveur POST /api/avoirs/verify-all, limité aux groupes accessibles et excluant les avoirs nocodbVerified. Il traite les vérifications en parallèle avec une concurrence limitée (4) et ne remplit le montant côté serveur que s'il est vide. Côté client : une seule mutation suivie d'une seule invalidation. En attendant, utiliser une mutation de remplissage automatique séparée, sans toast ni fermeture de modale, qui met à jour la ligne avec queryClient.setQueryData au lieu d'invalider la liste.
|
||||
|
||||
### AVOIRS-03
|
||||
|
||||
**Après Valider ou Dévalider, la liste n'est pas rafraîchie quand un magasin est sélectionné** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:528`
|
||||
- **Constat :** La queryKey est [avoirsUrl, selectedStoreId, (user as any)?.role] avec avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}` (l.164-166). handleValidateAvoir et handleDevalidateAvoir appellent queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] }) (l.528 et l.560). TanStack compare les clés élément par élément : '/api/avoirs' ≠ '/api/avoirs?storeId=3', donc aucune requête n'est invalidée. Les autres mutations utilisent un prédicat includes('/api/avoirs') (l.265-269), qui fonctionne.
|
||||
- **Impact :** Dès qu'un magasin est sélectionné (cas le plus courant), un avoir validé reste dans l'onglet 'En cours'. Après 'Dévalider', l'état local est supprimé (l.551-555) mais avoir.nocodbVerified reste à true dans le cache : la ligne affiche toujours 'Validé' et reste dans 'Finalisés' jusqu'au rechargement de la page. L'utilisateur croit que l'action a échoué et recommence.
|
||||
- **Recommandation :** Correction auto limitée au minimum : remplacer aux l.528 et 560 par queryClient.invalidateQueries({ predicate: q => q.queryKey[0]?.toString().includes('/api/avoirs') || false }). La normalisation de la clé ['/api/avoirs', {storeId}] avec un queryFn dédié (bureau et mobile) se fait à part, sans automatisation.
|
||||
|
||||
### AVOIRS-04
|
||||
|
||||
**Envoi du PDF impossible pour directeur et employé, et appel direct au webhook externe depuis le navigateur** — bug, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:776`
|
||||
- **Constat :** const groups = await queryClient.fetchQuery({ queryKey: ['/api/groups'] }); const group = (groups as any[]).find(g => g.id === groupId); if (!group?.webhookUrl) { toast({ ... description: 'Aucun webhook configuré pour ce magasin' }) } (l.776-786). Pour un non-admin, GET /api/groups renvoie (user as any).userGroups?.map((ug: any) => ug.group) (server/routes.ts:983). Ces groupes viennent de getUserWithGroups, qui ne sélectionne que g.id, g.name, g.color, created_at et updated_at (server/storage.ts:376-399) : webhookUrl est donc toujours undefined. Le bouton Upload est affiché si role !== 'manager' (l.1063), donc aux directeurs et aux employés. L'envoi se fait ensuite par fetch(group.webhookUrl, { method: 'POST', body: formData }) (l.805) directement depuis le navigateur, alors que BLReconciliation passe par le proxy '/api/reconciliation/send-invoice' (BLReconciliation.tsx:587-592, routes.ts:883), justement pour les « problèmes CORS ».
|
||||
- **Impact :** Pour un directeur ou un employé, le bouton 'Envoyer fichier avoir' échoue toujours avec un message incompréhensible. Pour un admin, l'envoi dépend du CORS du service externe et l'URL du webhook est visible dans le navigateur.
|
||||
- **Recommandation :** Créer POST /api/avoirs/:id/send-file. Le serveur vérifie l'accès au groupe de l'avoir, lit webhookUrl via storage.getGroup(avoir.groupId) et relaie le fichier (même parseur multipart que send-invoice). Ne pas réutiliser send-invoice, qui fait confiance à une URL fournie par le client. Décider explicitement si les employés peuvent envoyer ce fichier.
|
||||
|
||||
### AVOIRS-05
|
||||
|
||||
**Badge « Validé » affiché alors que l'avoir n'est pas validé (la facture a seulement été trouvée)** — ux-simplicite, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:1037`
|
||||
- **Constat :** {(avoirVerificationResults[avoir.id]?.exists === true || avoir.nocodbVerified) ? (<div className='... bg-green-100 text-green-800 ...'><CheckCircle .../>Validé</div>) (l.1037-1041). Dans la même ligne : {avoirVerificationResults[avoir.id]?.exists === true && !avoir.nocodbVerified && (<Button ... title="Valider l'avoir"><CheckCircle/></Button>)} (l.1076-1086).
|
||||
- **Impact :** L'utilisateur voit 'Validé' en vert et, en même temps, un bouton vert ✓ 'Valider'. Il pense que le travail est fait, et l'avoir ne passe jamais dans 'Finalisés'. C'est le flux principal de la page qui devient ambigu.
|
||||
- **Recommandation :** Afficher 3 états explicites avec du texte : 'Non vérifié' (gris, avec un bouton 'Vérifier'), 'Facture trouvée – à valider' (bleu ou orange, avec un bouton texte 'Valider'), et 'Validé' (vert) uniquement si avoir.nocodbVerified.
|
||||
|
||||
### AVOIRS-API-01
|
||||
|
||||
**La liste renvoie la ligne complète du magasin (logo base64, SMTP, webhook) dans chaque avoir** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2546`
|
||||
- **Constat :** getAvoirs sélectionne supplier: suppliers, group: groups, creator: { id, firstName, lastName, username, email: users.email } (l.2546-2554) avec .leftJoin(groups, eq(avoirs.groupId, groups.id)). La table groups contient logo: text('logo'), // Logo en data URI (data:image/png;base64,...) (shared/schema.ts:67), smtpHost, smtpUser et smtpSenderEmail (l.70-76), webhookUrl (l.63), address et phone. getAvoir fait de même (l.2593-2594) ; il est appelé par chaque PUT, DELETE et verify-invoice. Le middleware global stripSmtpPassword (server/routes.ts:168-174, server/sanitize.ts:13-45) parcourt ensuite récursivement tous les objets de la réponse.
|
||||
- **Impact :** Le logo (souvent plusieurs dizaines à centaines de Ko) est répété dans chaque avoir : environ 200 avoirs × 100 Ko ≈ 20 Mo de JSON pour une seule liste, soit un chargement très lent, surtout en 4G. Sérialisation et nettoyage récursif consomment du CPU. webhookUrl et la configuration SMTP sont exposés aux employés et aux managers.
|
||||
- **Recommandation :** Projection : group {id, name, color, nocodbTableName, nocodbConfigId}, supplier {id, name}, creator {id, firstName, lastName, username}, dans getAvoirs et getAvoir. Mettre à jour AvoirWithRelations (ou un type dédié) pour que tsc passe. MemStorage peut rester tel quel.
|
||||
|
||||
### MOB-AVOIRS-01
|
||||
|
||||
**Liste vide « Aucun avoir trouvé » quand « Tous les magasins » est sélectionné** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:83`
|
||||
- **Constat :** queryFn: async () => { if (!selectedStoreId) return []; ... }, enabled: !!selectedStoreId && !!user (l.82-88). MobileLayout propose 'Tous les magasins' (MobileLayout.tsx:28, 40-46), et MobileApp ne présélectionne un magasin que s'il n'y en a qu'un (MobileApp.tsx:37). L'état vide affiche <p>Aucun avoir trouvé</p> (l.200-204).
|
||||
- **Impact :** Un admin ou un directeur multi-magasins ouvre la page et lit 'Aucun avoir trouvé' alors que des avoirs existent. Il croit qu'il n'y en a pas.
|
||||
- **Recommandation :** Appeler /api/avoirs sans storeId quand aucun magasin n'est sélectionné (le serveur filtre déjà selon le rôle, routes.ts:3721-3733). À défaut, afficher 'Choisissez un magasin en haut de l'écran pour voir ses avoirs'.
|
||||
|
||||
### MOB-AVOIRS-02
|
||||
|
||||
**Suppression sans confirmation, proposée à tous les rôles, avec échec silencieux** — ux-simplicite, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:257`
|
||||
- **Constat :** <DropdownMenuItem onClick={() => deleteMutation.mutate(avoir.id)} className='text-red-600'> (l.257-263). deleteMutation et updateStatusMutation n'ont pas d'onError (l.113-127). Le serveur refuse la suppression aux rôles autres qu'admin et directeur (routes.ts:3960-3962).
|
||||
- **Impact :** Sur téléphone, un tap malheureux supprime définitivement un avoir pour un admin. Pour un manager ou un employé, rien ne se passe et aucun message n'apparaît.
|
||||
- **Recommandation :** Ajouter une confirmation par AlertDialog. Masquer 'Supprimer' pour les rôles autres qu'admin et directeur. Ajouter un onError qui affiche un toast en français.
|
||||
|
||||
### AVOIRS-06
|
||||
|
||||
**Statut modifié immédiatement par un menu déroulant dans chaque ligne, sans confirmation, avec effet externe** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:994`
|
||||
- **Constat :** <Select value={avoir?.status || ''} onValueChange={(newStatus) => handleStatusChange(avoir.id, newStatus)} disabled={avoir.nocodbVerified}> (l.994-1009) envoie un PUT immédiatement. Le serveur envoie un webhook quand le statut passe à 'Reçu' (server/routes.ts:3891-3928). Pour un rôle autre qu'admin ou directeur, la même ligne affiche {!canEditDelete && avoir?.status !== 'Reçu' && (<span>Lecture seule</span>)} (l.1136-1138) alors que le menu reste modifiable.
|
||||
- **Impact :** Un clic accidentel change le statut et envoie une notification irréversible au système externe. La mention 'Lecture seule' contredit un menu modifiable. Un Select large dans chaque ligne alourdit le tableau.
|
||||
- **Recommandation :** Afficher le statut sous forme de badge coloré (gris 'À demander', bleu 'Demandé', vert 'Reçu'). Ajouter un seul bouton texte contextuel ('Marquer comme demandé' puis 'Marquer comme reçu'), avec confirmation pour 'Reçu'. Supprimer la mention 'Lecture seule'.
|
||||
|
||||
### AVOIRS-07
|
||||
|
||||
**Jusqu'à 6 boutons-icônes sans texte par ligne et une loupe de 16 px** — ux-simplicite, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:1060`
|
||||
- **Constat :** La colonne Actions contient Upload (bleu), CheckCircle 'Valider' (vert), XCircle 'Dévalider' (orange), MessageSquare 'Voir le commentaire' (violet), Edit et Trash2 (rouge), tous en className='h-8 w-8 p-0' avec seulement un title= (l.1062-1135). La colonne Vérification contient <button className='h-4 w-4 text-gray-400 ...' title='Cliquer pour vérifier la facture'><Search className='h-4 w-4'/></button> (l.1047-1053). Le commentaire n'est lisible que dans une modale (l.1102-1112), alors que le mobile l'affiche dans la carte (mobile/AvoirsPage.tsx:268-272).
|
||||
- **Impact :** Un utilisateur non technicien doit survoler chaque icône pour la comprendre, et le title ne fonctionne pas sur écran tactile. Une cible de 16 px est difficile à cliquer. Les 6 couleurs n'ont pas de légende.
|
||||
- **Recommandation :** Même recommandation, en corrigeant le constat : jusqu'à 5 icônes par ligne.
|
||||
|
||||
### AVOIRS-08
|
||||
|
||||
**Onglet « Finalisés » entièrement grisé et semi-transparent** — lisibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:989`
|
||||
- **Constat :** <tr className={`hover:bg-gray-50 ${avoir?.nocodbVerified ? 'bg-gray-100 opacity-75' : ''}`}> (l.989), alors que completedAvoirs = avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified) (l.911). Toutes les lignes de cet onglet sont donc à 75 % d'opacité. Les dates sont déjà en text-gray-500 (l.1033).
|
||||
- **Impact :** Le contraste est insuffisant (gris 500 à 75 % sur gris 100, sous 4,5:1). L'historique, justement consulté pour des vérifications, devient difficile à lire.
|
||||
- **Recommandation :** Supprimer opacity-75 et bg-gray-100. Le badge 'Validé' suffit à distinguer ces lignes.
|
||||
|
||||
### AVOIRS-09
|
||||
|
||||
**Messages techniques (NocoDB, webhook) et erreurs brutes en anglais affichés à l'utilisateur** — lisibilite, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:666`
|
||||
- **Constat :** 'Ce magasin n'a pas de configuration NocoDB' (l.666) ; 'Aucun webhook configuré pour ce magasin' (l.782) ; 'Avoir traité avec succès via le webhook' (l.821) ; 'Envoi du fichier vers le webhook... {processingSeconds}s' (l.1695). Les mutations font throw new Error('Failed to create avoir') (l.259), 'Failed to edit avoir' (l.297) et 'Failed to delete avoir' (l.332), affichées via description: error.message (l.279, 318, 353). apiRequest produit des erreurs de la forme `${res.status}: ${text}` (client/src/lib/queryClient.ts:3-8), d'où un toast du type '403: {"message":"Insufficient permissions"}' (l.537).
|
||||
- **Impact :** Les messages sont incompréhensibles pour un employé de magasin, qui ne sait pas quoi faire.
|
||||
- **Recommandation :** Créer getErrorMessage(err) côté client, utilisé seulement au moment d'afficher le toast. Il lit le code HTTP en tête du message et le traduit (401 « Session expirée », 403 « Vous n'avez pas les droits pour cette action », 404 « Avoir introuvable », 400 « Données invalides », 5xx « Erreur serveur, réessayez »). Ne pas modifier le format des erreurs de apiRequest. Reformuler en français simple les textes 'NocoDB' et 'webhook'.
|
||||
|
||||
### AVOIRS-10
|
||||
|
||||
**Bouton « Valider » proposé à des rôles que le serveur refuse** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:1076`
|
||||
- **Constat :** Côté client : {avoirVerificationResults[avoir.id]?.exists === true && !avoir.nocodbVerified && (<Button ... onClick={() => handleValidateAvoir(avoir.id)}>)} (l.1076), sans contrôle de rôle. Côté serveur : if (user.role !== 'admin' && user.role !== 'directeur') { return res.status(403).json({ message: 'Insufficient permissions' }) } (server/routes.ts:4094-4096).
|
||||
- **Impact :** Un manager ou un employé qui clique reçoit un toast rouge 'Erreur de validation : 403: {"message":"Insufficient permissions"}'.
|
||||
- **Recommandation :** Ajouter && canEditDelete (défini l.928) à la condition d'affichage du bouton.
|
||||
|
||||
### AVOIRS-11
|
||||
|
||||
**Requête /api/user redondante qui bloque toutes les autres (cascade)** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:147`
|
||||
- **Constat :** const { data: user } = useQuery({ queryKey: ['/api/user'] }); (l.147-149), puis groups, suppliers et avoirs en enabled: !!user (l.154, 160, 188), et if (!user) { return <div>Chargement...</div>; } (l.1149-1151). En production, l'authentification passe par useAuthUnified en fetch direct, la variante react-query étant en enabled: isDevelopment (client/src/hooks/useAuthUnified.ts:43, 147-217). Le cache ['/api/user'] n'est donc jamais rempli.
|
||||
- **Impact :** Chaque affichage ajoute un aller-retour séquentiel avant de lancer les 3 requêtes, elles-mêmes suivies des N vérifications. Pendant ce temps, l'utilisateur voit une page blanche avec un 'Chargement...' sans mise en forme.
|
||||
- **Recommandation :** Remplir ['/api/user'] depuis useAuthUnified (setQueryData) ou lire l'utilisateur via useAuthUnified(). Retirer (user as any)?.role de la queryKey des avoirs avant de supprimer enabled: !!user. Remplacer 'Chargement...' par le skeleton.
|
||||
|
||||
### AVOIRS-12
|
||||
|
||||
**Formulaire de création non réinitialisé, et magasin par défaut qui ignore le magasin sélectionné** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:263`
|
||||
- **Constat :** Le onSuccess de createAvoirMutation invalide la liste, ferme la modale (setIsCreateDialogOpen(false)) et affiche un toast, sans form.reset() (l.263-275). defaultValues: { supplierId: 0, groupId: getDefaultGroupId(), ... } (l.374-376) est évalué au premier rendu. getDefaultGroupId renvoie 1 pour un admin, sinon le premier groupe de l'utilisateur, et 0 si l'utilisateur n'est pas encore chargé (l.360-369). selectedStoreId (l.141) n'est pas utilisé.
|
||||
- **Impact :** En rouvrant 'Nouvel avoir', on retrouve les données de l'avoir précédent, d'où un risque de doublon. L'utilisateur qui a choisi le magasin X en haut doit le resélectionner ; un admin risque de créer l'avoir sur le magasin 1.
|
||||
- **Recommandation :** À l'ouverture de la modale et après un succès, appeler form.reset({ ...valeursParDéfaut, groupId: selectedStoreId ?? getDefaultGroupId() }).
|
||||
|
||||
### AVOIRS-13
|
||||
|
||||
**Filtrages non mémoïsés et code mort exécuté à chaque frappe, avec un risque de plantage** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:871`
|
||||
- **Constat :** const filteredAvoirs = avoirs.filter(avoir => ... (avoir.creator.firstName && avoir.creator.lastName ? ... : avoir.creator.username.toLowerCase().includes(...))) (l.871-879) n'est jamais utilisé. pendingAvoirs, completedAvoirs et filterAvoirs sont recalculés à chaque rendu (l.910-926), avec un searchTerm.toLowerCase() par élément. renderAvoirTable(filteredPendingAvoirs) et renderAvoirTable(filteredCompletedAvoirs) sont tous deux évalués (l.1246 et l.1263) alors qu'un seul onglet est visible. creator provient d'un LEFT JOIN sur users (server/storage.ts:2548-2559) : si le créateur a été supprimé, Drizzle renvoie creator = null et avoir.creator.firstName lève une TypeError qui fait planter toute la page (plausible).
|
||||
- **Impact :** À chaque frappe dans la recherche : 4 parcours de la liste et la construction du JSX des deux tableaux. Risque de page blanche.
|
||||
- **Recommandation :** Supprimer filteredAvoirs. Calculer pending, completed et les listes filtrées dans des useMemo, en passant la recherche par useDeferredValue(searchTerm). Ne rendre que le tableau de l'onglet actif. Utiliser le chaînage optionnel sur creator.
|
||||
|
||||
### AVOIRS-14
|
||||
|
||||
**Aucune pagination : tout l'historique est rendu avec un Select Radix par ligne** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:988`
|
||||
- **Constat :** {avoirsList?.filter(avoir => avoir && avoir.id).map((avoir) => (<tr ...> ... <Select ...> (l.988-1009). Côté serveur, getAvoirs n'a pas de LIMIT : const results = await query.orderBy(desc(avoirs.createdAt)); (server/storage.ts:2565). L'onglet 'Finalisés' grossit indéfiniment.
|
||||
- **Impact :** Après une année d'utilisation (des centaines d'avoirs), le DOM est lourd, et le rendu comme la recherche ralentissent. Chaque Select ajoute plusieurs composants Radix et leurs écouteurs.
|
||||
- **Recommandation :** Paginer par 50, ou limiter 'Finalisés' aux 3 derniers mois avec un bouton 'Voir plus'. Remplacer le Select de chaque ligne par un badge (voir AVOIRS-06).
|
||||
|
||||
### AVOIRS-15
|
||||
|
||||
**console.log de débogage actifs en production dans les chemins chauds** — dette-code, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:168`
|
||||
- **Constat :** Dans le queryFn : console.log('💰 Fetching avoirs from:', avoirsUrl) puis console.log('🔍 Premiers avoirs avec nocodbVerified:', data.map(a => ({ id: a.id, invoiceReference: ..., nocodbVerified: ... }))) (l.168-184), qui parcourt toute la liste à chaque fetch. Dans la boucle : console.log('🔍 LoadCache - Avoir analysé:', {...}) pour chaque avoir (l.205-210), ainsi qu'aux l.220, 224 et 233-240. Autres logs aux l.414-420, 525, 557, 600 et 672-677. Aucun n'est protégé par import.meta.env.DEV, contrairement à apiRequest (queryClient.ts:17).
|
||||
- **Impact :** Console saturée, allocations inutiles, et références et montants exposés dans la console du poste du magasin.
|
||||
- **Recommandation :** Supprimer ces console.log, surtout ceux dont les arguments calculent quelque chose (l.178 data.map, l.205-210). Ce nettoyage est sans risque, mais le gain en production est minime, car le minifieur retire déjà les appels.
|
||||
|
||||
### AVOIRS-API-02
|
||||
|
||||
**Ni pagination ni filtre de statut en SQL : la séparation En cours / Finalisés se fait en JavaScript** — perf-api, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2561`
|
||||
- **Constat :** if (groupIds && groupIds.length > 0) { query = query.where(inArray(avoirs.groupId, groupIds)); } const results = await query.orderBy(desc(avoirs.createdAt)); (l.2561-2565), sans limite. Le client sépare ensuite les listes : pendingAvoirs = avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified)) (Avoirs.tsx:910-911). Un admin sans magasin sélectionné reçoit tous les avoirs de tous les magasins (routes.ts:3721-3722).
|
||||
- **Impact :** La réponse grossit avec l'historique. L'essentiel des données transférées concerne des avoirs finalisés rarement consultés.
|
||||
- **Recommandation :** Ajouter les paramètres ?scope=pending|completed&limit=50&offset=, traduits en SQL par WHERE (status <> 'Reçu' OR nocodb_verified = false). Ne charger 'Finalisés' qu'à l'ouverture de l'onglet (useQuery avec enabled: activeTab === 'completed'). L'index (group_id, status) existe déjà (migrations/20260814_add_performance_indexes.sql).
|
||||
|
||||
### AVOIRS-API-03
|
||||
|
||||
**Logs JSON volumineux à chaque création, modification et vérification** — perf-serveur, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:3874`
|
||||
- **Constat :** PUT : console.log('💰 PUT Avoir - Raw body received:', JSON.stringify(req.body, null, 2)), puis les logs 'Validated data' et 'Data for DB' (l.3874, 3876, 3885). POST : l.3782, 3783 et 3791. GET : l.3735 et 3737. verify-invoice : l.4032-4038 et 4048. invoiceVerification.checkCache écrit 2 à 3 logs par appel (server/invoiceVerification.ts:22-31, 35-41, 58), et getInvoiceVerificationCache en ajoute (server/storage.ts:1718-1736). Le mobile envoie l'avoir complet, groupe et logo compris, dans le PUT (mobile/AvoirsPage.tsx:239-256), qui est donc journalisé 3 fois en pretty-print.
|
||||
- **Impact :** Les écritures synchrones sur stdout bloquent la boucle d'événements. Les logs gonflent de plusieurs centaines de Ko par changement de statut, multipliés par N pendant la boucle de vérification.
|
||||
- **Recommandation :** Supprimer les console.log de données (et non les console.error), ou les placer derrière un if (process.env.DEBUG). Ne jamais journaliser req.body en entier.
|
||||
|
||||
### AVOIRS-API-04
|
||||
|
||||
**Création et modification bloquées par l'appel au webhook externe, sans timeout** — perf-api, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3821`
|
||||
- **Constat :** POST : const webhookResponse = await fetch(group.webhookUrl, { method: 'POST', headers: {...}, body: JSON.stringify(webhookData) }); est attendu avant res.json(avoir) (l.3821-3840). Le PUT fait de même lors du passage à 'Reçu' (l.3914-3934). Aucun AbortSignal ni timeout.
|
||||
- **Impact :** Si le service externe est lent ou indisponible, le bouton 'Création...' reste bloqué jusqu'au timeout TCP (de quelques dizaines de secondes à plusieurs minutes), alors que l'avoir est déjà enregistré. L'utilisateur reclique et crée des doublons.
|
||||
- **Recommandation :** Répondre au client tout de suite, puis envoyer le webhook en arrière-plan (sans await, ou via une file de tâches) avec signal: AbortSignal.timeout(5000), et appeler updateAvoirWebhookStatus une fois l'envoi terminé.
|
||||
|
||||
### AVOIRS-API-05
|
||||
|
||||
**Le webhook avoir est envoyé avec un nom de fournisseur factice** — bug, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:3813`
|
||||
- **Constat :** supplierName: 'Unknown', // Will be fetched from relations (l.3813) et supplierName: 'Fournisseur', // Sera enrichi avec relations (l.3903) ne sont jamais enrichis. Pourtant existingAvoir, issu de getAvoir (l.3859), contient déjà supplier.name.
|
||||
- **Impact :** Le système externe (comptabilité, automatisation) reçoit 'Unknown' ou 'Fournisseur' au lieu du vrai fournisseur, ce qui impose un tri manuel.
|
||||
- **Recommandation :** POST : après createAvoir, lire const full = await storage.getAvoir(avoir.id) et utiliser full?.supplier?.name ?? ''. PUT : si validatedData.supplierId est défini et différent de existingAvoir.supplierId, relire l'avoir avec getAvoir(id) ; sinon utiliser existingAvoir.supplier?.name.
|
||||
|
||||
### AVOIRS-API-06
|
||||
|
||||
**Le PUT efface montant, référence et commentaire s'ils ne sont pas envoyés, ce qui oblige à renvoyer tout l'objet** — bug, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:3879`
|
||||
- **Constat :** const dataForDb: any = { ...validatedData, amount: validatedData.amount === undefined ? null : validatedData.amount, invoiceReference: ... === undefined ? null : ..., comment: ... === undefined ? null : ... }; (l.3879-3884), après insertAvoirSchema.partial().parse(req.body) (l.3875). Conséquence : le bureau reconstruit tout le payload pour un simple changement de statut (Avoirs.tsx:483-494, 601-612), et le mobile envoie { ...avoir, status } avec toutes les relations (mobile/AvoirsPage.tsx:239-256).
|
||||
- **Impact :** Un PUT { status: 'Reçu' } effacerait le montant, la référence et le commentaire. Les requêtes sont plus lourdes que nécessaire.
|
||||
- **Recommandation :** D'abord modifier le client pour qu'il envoie explicitement amount: null quand le champ est vide (Avoirs.tsx:408). Ensuite seulement, côté serveur, ne convertir en null que les clés présentes dans req.body. Les clients pourront alors n'envoyer que { status }.
|
||||
|
||||
### AVOIRS-API-07
|
||||
|
||||
**Règles métier appliquées seulement côté client (statut « Reçu », avoirs validés, changement de magasin)** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3866`
|
||||
- **Constat :** Le bureau interdit aux managers de passer un avoir en 'Reçu' (Avoirs.tsx:474-481) et bloque les avoirs validés (l.464-471). Le PUT serveur ne vérifie que le magasin de l'avoir existant : if (!userGroupIds.includes(existingAvoir.groupId)) (routes.ts:3866-3871). Il ne contrôle ni le rôle pour 'Reçu', ni nocodbVerified, ni l'accès au nouveau validatedData.groupId. Le mobile propose 'Marquer Reçu' à tous les rôles (mobile/AvoirsPage.tsx:251-256).
|
||||
- **Impact :** Sur mobile, un manager peut passer un avoir en 'Reçu' (ce qui déclenche le webhook), modifier un avoir validé ou déplacer un avoir vers un magasin qu'il ne gère pas. Le comportement diffère entre bureau et mobile.
|
||||
- **Recommandation :** Appliquer ces règles dans le PUT : 403 si le rôle est manager et que le statut passe à 'Reçu' ; 409 si existingAvoir.nocodbVerified et que le rôle n'est pas admin ; vérifier userGroupIds.includes(validatedData.groupId) quand groupId change.
|
||||
|
||||
### MOB-AVOIRS-03
|
||||
|
||||
**Menu de statut sans indication de l'état courant ni restrictions** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:239`
|
||||
- **Constat :** Les trois entrées 'Marquer En attente', 'Marquer Demandé' et 'Marquer Reçu' sont toujours affichées, y compris pour le statut courant (l.239-256). Aucun contrôle du rôle manager ni de nocodbVerified, contrairement au bureau (Avoirs.tsx:464-481, 1005-1007).
|
||||
- **Impact :** Clics inutiles et transitions non autorisées. Un 'Reçu' accidentel déclenche le webhook externe.
|
||||
- **Recommandation :** Afficher sur la carte un bouton visible pour la seule étape suivante ('Marquer comme demandé' puis 'Marquer comme reçu'), avec confirmation pour 'Reçu'. Masquer ces actions pour les avoirs validés.
|
||||
|
||||
### MOB-AVOIRS-04
|
||||
|
||||
**Le changement de statut envoie l'objet avoir complet avec ses relations** — perf-api, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:241`
|
||||
- **Constat :** updateStatusMutation.mutate({ id: avoir.id, data: { ...avoir, status: 'Reçu' } }) (l.239-256). L'objet avoir contient supplier, group (toutes ses colonnes, logo base64 compris, via getAvoirs) et creator.
|
||||
- **Impact :** Un corps de requête qui peut atteindre plusieurs centaines de Ko sur réseau mobile, simplement pour changer un statut, et journalisé 3 fois côté serveur.
|
||||
- **Recommandation :** Envoyer { supplierId, groupId, invoiceReference: avoir.invoiceReference ?? '', amount: avoir.amount ?? null, comment: avoir.comment ?? '', commercialProcessed: !!avoir.commercialProcessed, status }. Passer à { status } seul une fois AVOIRS-API-06 corrigé.
|
||||
|
||||
### MOB-AVOIRS-05
|
||||
|
||||
**Montant forcé à 0 € lors d'une création sur mobile** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:135`
|
||||
- **Constat :** amount: z.coerce.number().optional() (l.65) et defaultValues: { ..., amount: 0, ... } (l.135). z.coerce convertit aussi un champ vidé ('') en 0. L'affichage {avoir.amount && (<span>{parseFloat(avoir.amount).toFixed(2)} €</span>)} (l.224-228) reçoit le décimal sous forme de chaîne '0.00', qui est truthy.
|
||||
- **Impact :** Tout avoir créé sur mobile sans montant est enregistré à 0,00 € au lieu de 'non spécifié'. Le mobile affiche '0.00 €' et le bureau aussi, au lieu de 'Non spécifié'.
|
||||
- **Recommandation :** Mettre amount: '' par défaut plutôt que undefined, pour éviter l'avertissement React sur le passage d'un champ non contrôlé à contrôlé. Utiliser amount: z.preprocess(v => v === '' || v == null ? undefined : v, z.coerce.number().optional()).
|
||||
|
||||
### MOB-AVOIRS-06
|
||||
|
||||
**Erreurs de validation invisibles dans le formulaire mobile** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:46`
|
||||
- **Constat :** Seuls Form, FormControl, FormField, FormItem et FormLabel sont importés (l.46-52) : il n'y a aucun FormMessage. Le schéma contient pourtant supplierId: z.coerce.number().min(1, 'Fournisseur requis') (l.63).
|
||||
- **Impact :** Un tap sur 'Créer la demande' sans fournisseur ne produit rien : ni message ni retour visuel.
|
||||
- **Recommandation :** Ajouter <FormMessage /> sous chaque champ, et un toast d'erreur lorsque la soumission est invalide.
|
||||
|
||||
### MOB-AVOIRS-07
|
||||
|
||||
**Requêtes /api/user répétées et cascade avant le chargement des avoirs** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:72`
|
||||
- **Constat :** const { user } = useAuthUnified(); (l.72) et enabled: !!selectedStoreId && !!user (l.88). En production, useAuthUnified exécute fetch('/api/user', { cache: 'no-cache' }) dans un useEffect à chaque montage (useAuthUnified.ts:147-217). MobileLayout appelle aussi useAuthUnified (MobileLayout.tsx:22).
|
||||
- **Impact :** Chaque ouverture de la page coûte 2 requêtes /api/user supplémentaires, et la liste attend l'une d'elles, soit un aller-retour de plus sur réseau mobile (souvent 200 à 500 ms en 4G).
|
||||
- **Recommandation :** Exposer l'utilisateur via un contexte, ou via le cache react-query rempli une seule fois (setQueryData(['/api/user'])) avec un staleTime long. Retirer la condition sur user dans enabled.
|
||||
|
||||
### AVOIRS-16
|
||||
|
||||
**Compteurs affichés trois fois et deux composants Tabs séparés** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:1168`
|
||||
- **Constat :** Badges '{pendingAvoirs.length} en cours' et '{completedAvoirs.length} finalisés' dans l'en-tête (l.1168-1173), mêmes compteurs dans les onglets (l.1200-1209), puis '{filteredPendingAvoirs.length} éléments' et '{filteredCompletedAvoirs.length} éléments' dans l'en-tête de la carte (l.1239, 1256). Deux <Tabs value={activeTab}> distincts (l.1195 et l.1233).
|
||||
- **Impact :** Surcharge visuelle : la même information répétée trois fois, sans valeur ajoutée.
|
||||
- **Recommandation :** Ne garder que les compteurs des onglets. Utiliser un seul composant Tabs englobant TabsList et TabsContent. Supprimer le CardHeader redondant.
|
||||
|
||||
### AVOIRS-17
|
||||
|
||||
**Formats incohérents : « #Sans référence », montants « 12.50 € », couleur CSS invalide** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:1012`
|
||||
- **Constat :** #{avoir.invoiceReference || 'Sans référence'} (l.1012) affiche '#Sans référence'. `${Number(avoir.amount).toFixed(2)} €` (l.1025) donne '12.50 €' au lieu de '12,50 €'. backgroundColor: avoir.group?.color || '#gray' (l.1018) n'est pas une couleur CSS valide. Abréviation 'Sans réf.' (l.1057).
|
||||
- **Impact :** Rendu peu professionnel, et le point décimal trouble des utilisateurs francophones.
|
||||
- **Recommandation :** Créer un helper partagé formatEuro = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }). Afficher '—' quand il n'y a pas de référence. Utiliser '#9CA3AF' comme couleur de repli.
|
||||
|
||||
### AVOIRS-18
|
||||
|
||||
**La règle « un admin peut modifier un avoir validé » est inaccessible depuis le menu de statut** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:997`
|
||||
- **Constat :** handleStatusChange laisse passer l'admin : if (avoir?.nocodbVerified && (user as any)?.role !== 'admin') (l.464). Mais le Select porte disabled={avoir.nocodbVerified} pour tous les rôles (l.997). Le bouton Modifier, lui, reste autorisé pour l'admin sur un avoir validé (l.1114).
|
||||
- **Impact :** Comportement incohérent : l'admin doit passer par la modale d'édition pour changer le statut.
|
||||
- **Recommandation :** Trancher la règle métier : soit disabled={avoir.nocodbVerified && role !== 'admin'}, soit supprimer la branche admin devenue inutile dans handleStatusChange.
|
||||
|
||||
### AVOIRS-19
|
||||
|
||||
**Double marquage du cache lors de la validation, et validation hors useMutation** — perf-api, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:510`
|
||||
- **Constat :** handleValidateAvoir appelle await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', { verified: true }) puis await apiRequest('/api/cache/mark-reconciled', 'POST', {...}) (l.502-513). Or la route PUT nocodb-verification exécute déjà invoiceVerificationService.updateCacheAsReconciled(...) (server/routes.ts:4101-4110). Ces fonctions async ne passent pas par useMutation : aucun état 'en cours', et un double clic envoie deux requêtes.
|
||||
- **Impact :** Un aller-retour inutile à chaque validation (avec 3 requêtes BD), et des doubles soumissions possibles.
|
||||
- **Recommandation :** Supprimer l'appel à mark-reconciled. Passer la validation dans un useMutation et désactiver le bouton pendant isPending.
|
||||
|
||||
### AVOIRS-20
|
||||
|
||||
**Timer du modal d'attente jamais nettoyé, et modal fermable pendant l'envoi** — bug, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:746`
|
||||
- **Constat :** const interval = setInterval(() => { setProcessingSeconds(prev => prev + 1); }, 1000); setProcessingTimeout(interval); (l.746-752), sans nettoyage dans un useEffect au démontage. <Dialog open={showWaitingModal} onOpenChange={handleCloseWaitingModal}> (l.1683) permet de fermer avec Échap ou un clic extérieur alors que le fetch continue. En cas d'erreur, setShowAvoirModal(true) (l.859) rouvre la modale de sélection.
|
||||
- **Impact :** L'intervalle continue de tourner si l'utilisateur change de page. Les états affichés deviennent confus si la modale est fermée pendant l'envoi.
|
||||
- **Recommandation :** Stocker l'intervalle dans un useRef (intervalRef.current), le nettoyer dans handleCloseWaitingModal via la ref et dans un useEffect de démontage, et supprimer l'état processingTimeout. Pour la modale d'attente, ajouter onInteractOutside et onEscapeKeyDown avec preventDefault tant que isUploading, et masquer la croix de fermeture pendant l'envoi.
|
||||
|
||||
### AVOIRS-21
|
||||
|
||||
**Code mort, imports inutilisés et typage faible** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Avoirs.tsx:882`
|
||||
- **Constat :** getStatusVariant (l.882-893) et getStatusIcon (l.896-907) ne sont jamais appelés. Imports inutilisés : Send et Settings (l.3), DialogTrigger et DialogFooter (l.10). key={avoir?.id || Math.random()} (l.989). (user as any) est répété une quinzaine de fois. Le montant d'édition est géré hors react-hook-form, via editAmountValue (l.135, 1536-1546).
|
||||
- **Impact :** 1731 lignes difficiles à maintenir, ce qui augmente le risque de régression lors de la refonte UX.
|
||||
- **Recommandation :** Partie automatisable uniquement : supprimer getStatusVariant et getStatusIcon, ainsi que les imports Send, Settings, DialogTrigger, DialogFooter et AlertCircle (ce dernier devient inutilisé, Clock reste utilisé l.1198), et remplacer key par avoir.id. Typer user avec UserWithGroups, pas User. Le passage du montant en FormField et le découpage en composants se font à part, sans automatisation.
|
||||
|
||||
### AVOIRS-22
|
||||
|
||||
**Pages Avoirs (bureau et mobile) chargées dans le bundle initial** — perf-bundle, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:26`
|
||||
- **Constat :** import Avoirs from '@/pages/Avoirs'; (l.26) et import MobileAvoirsPage from '@/pages/mobile/AvoirsPage'; (l.44) sont des imports statiques. Aucun React.lazy dans client/src. manualChunks ne sépare que les dépendances (vite.config.ts:41-46).
|
||||
- **Impact :** Les 1731 lignes de la page, plus react-hook-form, zod et date-fns, sont téléchargées par tous les utilisateurs, y compris ceux qui n'ouvrent jamais les avoirs. Les versions bureau et mobile sont toutes deux téléchargées.
|
||||
- **Recommandation :** const Avoirs = lazy(() => import('@/pages/Avoirs')), et de même pour MobileAvoirsPage, avec un <Suspense> qui affiche un skeleton.
|
||||
|
||||
### AVOIRS-API-08
|
||||
|
||||
**Périmètre du directeur incohérent d'une route avoirs à l'autre** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3721`
|
||||
- **Constat :** GET /api/avoirs : admin comme directeur voient tous les magasins quand aucun storeId n'est passé (l.3721-3722). verify-invoice refuse tout non-admin hors de ses groupes (l.4002-4013). DELETE vérifie le groupe du directeur (l.3965-3970). nocodb-verification ne vérifie aucun groupe (l.4093-4098). /api/groups ne renvoie au directeur que ses propres groupes (l.978-985).
|
||||
- **Impact :** Un directeur voit les avoirs des autres magasins, mais leur vérification renvoie un 403 avalé sans message par la boucle (Avoirs.tsx:226-228). Le formulaire ne propose pas ces magasins, et le directeur peut valider n'importe quel avoir par son identifiant.
|
||||
- **Recommandation :** Centraliser le calcul dans un helper getAccessibleGroupIds(user), utilisé par toutes les routes /api/avoirs*.
|
||||
|
||||
### AVOIRS-API-09
|
||||
|
||||
**verify-invoice recharge des données déjà disponibles et ne met pas en cache les résultats négatifs** — perf-serveur, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/invoiceVerification.ts:285`
|
||||
- **Constat :** La route charge déjà l'avoir avec son groupe (routes.ts:3990, getAvoir joint groups), puis verifyInvoice exécute à nouveau const group = await storage.getGroup(groupId); (invoiceVerification.ts:285) et storage.getActiveNocodbConfig() (l.360) à chaque appel. getInvoiceVerificationCache supprime les entrées expirées une par une (storage.ts:1730-1731). Chaque appel exécute aussi getUserWithGroups, soit 2 requêtes (storage.ts:371-388). Le résultat 'Configuration NocoDB manquante' n'est pas mis en cache (invoiceVerification.ts:302-309).
|
||||
- **Impact :** Environ 6 requêtes BD par vérification, multipliées par N avoirs dans la boucle du client.
|
||||
- **Recommandation :** Ajouter un paramètre optionnel group à verifyInvoice, sans casser les autres appelants (deliveries, /api/verify-invoice). Mémoriser getActiveNocodbConfig (TTL 5 min) et l'invalider dans les routes de mise à jour NocoDB. Mettre en cache le résultat « pas de configuration » avec un TTL court (environ 5 min), invalidé à la mise à jour du magasin.
|
||||
|
||||
### MOB-AVOIRS-08
|
||||
|
||||
**Données de référence chargées inutilement et tri redondant** — perf-client, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:91`
|
||||
- **Constat :** La requête suppliers part au montage (l.91-94) alors qu'elle ne sert que dans la feuille de création. La requête groups (l.96-99) double les stores déjà fournis par StoreContext (MobileApp.tsx:29-32). const sortedAvoirs = [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); (l.158) trie à chaque rendu une liste que le serveur renvoie déjà triée DESC (storage.ts:2565). Le filtre est recalculé à chaque frappe, sans useMemo (l.160-163).
|
||||
- **Impact :** Requêtes et calculs inutiles à chaque ouverture et à chaque frappe sur un téléphone.
|
||||
- **Recommandation :** Supprimer le tri. Mémoïser le filtre avec useMemo. Remplacer la requête groups par stores de useStore() (même données). Charger les fournisseurs avec enabled: isCreateOpen et un staleTime de 10 min, en acceptant un court instant de liste vide à l'ouverture de la feuille, ou précharger au clic sur le bouton +.
|
||||
|
||||
### MOB-AVOIRS-09
|
||||
|
||||
**Libellés, couleurs et statuts différents de la version bureau** — coherence-design, sévérité basse, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:180`
|
||||
- **Constat :** Titre 'Suivi des Avoirs' en indigo (l.179-180), contre 'Gestion des Avoirs' en bleu au bureau (Avoirs.tsx:1159-1161). Badge 'En attente' (l.167) contre 'En attente de demande'. 'Vérifié Compta' (l.277) contre 'Validé' (Avoirs.tsx:1040). 'Déjà traité par commercial' (l.375) contre 'Avoir fait par commercial' (Avoirs.tsx:1415). Pas de séparation En cours / Finalisés. Date au format 'dd/MM' sans année (l.214). Abréviation 'Réf. Facture' (l.332). Spinner au chargement (l.196-199), contre un skeleton au bureau (Avoirs.tsx:932-941).
|
||||
- **Impact :** Un utilisateur qui passe du téléphone au PC doit réapprendre les termes. Les dates des avoirs anciens sont ambiguës.
|
||||
- **Recommandation :** Créer un module partagé shared/avoirStatus.ts (libellés et couleurs) et un composant StatusBadge commun. Ajouter des onglets ou des chips 'En cours / Finalisés' sur mobile. Afficher les dates en dd/MM/yy. Utiliser des cartes skeleton.
|
||||
|
||||
### MOB-AVOIRS-10
|
||||
|
||||
**Imports inutilisés, typage any et schéma dupliqué** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/AvoirsPage.tsx:19`
|
||||
- **Constat :** Filter, AlertCircle, Clock et Ban sont importés (l.19-25) mais pas utilisés. useForm n'est pas typé, et on trouve (data: any) et (a: any) aux l.103, 142, 158-163 et 206. Le schéma zod est dupliqué par rapport au bureau (Avoirs.tsx:76-101) et a divergé : pas de groupId, montant traité différemment.
|
||||
- **Impact :** Les écarts de comportement entre bureau et mobile se multiplient.
|
||||
- **Recommandation :** Partie automatisable : supprimer les imports Filter, AlertCircle, Clock et Ban. Le partage du schéma (insertAvoirSchema.omit({createdBy}), traitement du montant) se fait à part, sans automatisation.
|
||||
@@ -0,0 +1,434 @@
|
||||
# Tâches
|
||||
|
||||
_36 constats vérifiés — 6 haute, 25 moyenne, 5 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/tasks (desktop et tablette, largeur >= 768px)`
|
||||
|
||||
**Rôle :** Lister, filtrer, créer, modifier, terminer et supprimer les tâches du magasin sélectionné (liste paginée ou vue Kanban), avec des dates de début (visibilité programmée) et d'échéance.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Voir les tâches à faire aujourd'hui ou en retard
|
||||
- Marquer une tâche comme terminée
|
||||
- Créer une tâche (titre, priorité, dates, personne assignée)
|
||||
- Modifier ou supprimer une tâche
|
||||
- Filtrer par statut, priorité, échéance et rechercher
|
||||
- Basculer entre Liste et Kanban
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/user | au montage, une fois par composant qui appelle useAuthUnified/useAuthSimple (RouterProduction, Layout, Sidebar, Tasks), soit 4 appels, sans cache partagé (cache: 'no-cache') | server/localAuth.production.ts:246 (req.user déjà chargé par deserializeUser l.193-195 → storage.getUserWithGroups storage.ts:371, 2 requêtes SQL) | La requête des tâches attend la fin de l'appel /api/user propre à Tasks (enabled: !!user) : chargement en cascade. |
|
||||
| GET /api/tasks?storeId=X | au montage (enabled: !!user) + invalidation forcée par useEffect au montage et à chaque changement de magasin (Tasks.tsx:299-308) + invalidation par le sélecteur de magasin de Layout (Layout.tsx:138-146) | server/routes.ts:3193 → storage.getUserWithGroups (storage.ts:371, 2 requêtes) + storage.getTasks (storage.ts:2267, LEFT JOIN groups sur toutes les colonnes) | Aucune limite ni pagination. Chaque tâche embarque la ligne magasin complète, logo base64 compris. Renvoie [] pour un non-admin sans storeId. 6 console.log par appel. |
|
||||
| GET /api/users | au montage pour admin/manager/directeur (Tasks.tsx:385-396), résultat jamais utilisé | server/routes.ts:4268 → storage.getUsers (storage.ts:409, SELECT * avec password) + N x storage.getUserWithGroups (2 requêtes par utilisateur) | Requête inutile en N+1 qui renvoie aussi les hash de mots de passe au navigateur. |
|
||||
| POST /api/tasks | au clic sur Créer (fetch manuel dans TaskFormInline) puis window.location.reload() | server/routes.ts:3277 → storage.getUserWithGroups + storage.createTask (storage.ts:2365) | Pas de validation zod (insertTaskSchema inutilisé). Groupe 1 par défaut. Corps complet loggé. |
|
||||
| PUT /api/tasks/:id | au clic sur Modifier (fetch manuel) puis window.location.reload() | server/routes.ts:3340 → getUserWithGroups + storage.getTask (storage.ts:2348, jointure groups complète) + storage.updateTask (storage.ts:2392) | Pas de contrôle hasPermission (seule l'appartenance au magasin est vérifiée). |
|
||||
| DELETE /api/tasks/:id | au clic sur Supprimer dans la modale de confirmation | server/routes.ts:3425 → getUserWithGroups + storage.getTask + storage.deleteTask (storage.ts:2470) | Pas de hasPermission : un manager ou un employé peut supprimer. Le client attend le rechargement complet de la liste avant de fermer la modale. |
|
||||
| POST /api/tasks/:id/complete | au clic sur l'icône Terminer | server/routes.ts:3455 → getUserWithGroups + storage.getTask + storage.completeTask (storage.ts:2474) | Renvoie un simple message, pas la tâche mise à jour : le client doit recharger toute la liste. |
|
||||
|
||||
**Lisibilité / simplicité :** Page lourde et dense : chaque carte empile jusqu'à 7 informations (priorité, badge Future, '(Programmée)', 'Visible en avance (directeur)', badge début '📅 Active' avec texte, badge échéance '⏰', 'Créée le' avec l'heure, 'Assigné à: Non assigné'). Emojis et icônes lucide se doublent dans les mêmes badges. Sur desktop, les actions sont des icônes sans libellé ni infobulle. Un bug de date affiche 'En retard de 0 jour' pour les tâches dues aujourd'hui et 'Dans 0 jour' pour celles de demain. Les compteurs 'Tâches en cours (n)' ne portent que sur la page courante. Les tâches terminées sont presque illisibles (opacité 60 %, gris 400, texte barré). Sur tablette (768-1023 px), la recherche et les filtres sont inaccessibles et l'onglet Kanban est vide. Les modales sont faites à la main (pas d'Échap ni de focus) et le formulaire mélange styles inline et Tailwind. L'assignation est un champ texte libre, et un admin en vue 'Tous les magasins' crée la tâche dans le magasin 1 sans le savoir. Les erreurs réseau s'affichent comme 'Aucune tâche trouvée avec les filtres sélectionnés'. Après chaque création ou modification, la page entière est rechargée. Un employé sur desktop ne voit aucune tâche (pas de magasin sélectionné ni sélectionnable). Les droits affichés (supprimer, terminer) ne respectent pas la matrice de permissions.
|
||||
|
||||
### `/tasks (mobile, largeur < 768px)`
|
||||
|
||||
**Rôle :** Version mobile de la liste des tâches : onglets Actives/Faites/Toutes, recherche, bouton flottant de création, formulaire plein écran, suppression par feuille du bas.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Voir les tâches actives du magasin
|
||||
- Terminer une tâche
|
||||
- Créer ou modifier une tâche depuis le téléphone
|
||||
- Rechercher une tâche par titre
|
||||
- Supprimer une tâche
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/user | au montage, 5 fois (RouterProduction, MobileApp, MobileLayout, MobileBottomNav, TasksPage), chacun avec son propre fetch | server/localAuth.production.ts:246 | La requête des tâches attend l'appel propre à TasksPage (enabled: !!user). |
|
||||
| GET /api/tasks?storeId=X | au montage (enabled: !!user) | server/routes.ts:3193 → storage.getTasks (storage.ts:2267) | Même payload sans limite, avec la ligne magasin complète par tâche. Tout est rendu sans pagination (onglets Faites/Toutes). |
|
||||
| POST /api/tasks et PUT /api/tasks/:id | à la soumission du formulaire plein écran puis window.location.reload() | server/routes.ts:3277 / 3340 | Le formulaire mobile n'a ni date de début ni statut. |
|
||||
| POST /api/tasks/:id/complete | au clic sur Terminer (visible pour tous les rôles) | server/routes.ts:3455 | response.ok n'est pas vérifié : un toast de succès s'affiche même en cas d'erreur 403 ou 500. |
|
||||
| DELETE /api/tasks/:id | au clic sur Supprimer dans la feuille du bas | server/routes.ts:3425 | response.ok n'est pas vérifié non plus. |
|
||||
|
||||
**Lisibilité / simplicité :** Plus simple et plus lisible que le desktop (cartes compactes, gros boutons, onglets). Plusieurs écarts : vocabulaire différent ('Actives/Faites' contre 'En cours/Terminées'), couleurs de priorité différentes (rouge/jaune/vert contre rouge/bleu/gris), recherche sur le titre seulement (titre + description sur desktop). Les tâches programmées ne sont pas distinguées et peuvent être 'terminées'. 'Terminer' est proposé à tous les rôles alors que le desktop le réserve aux managers et plus. Le formulaire n'a pas de date de début. Les boutons icône (modifier, supprimer, +) n'ont pas de libellé accessible. Le toast de succès s'affiche même si l'API échoue. Spinner pendant le chargement, page entière rechargée après enregistrement, double marge horizontale (p-3 puis px-3).
|
||||
|
||||
### `(aucune route : code mort) client/src/components/tasks/* et client/src/components/ResponsiveTasks.tsx`
|
||||
|
||||
**Rôle :** Six variantes successives du formulaire de tâche, une carte 'tâches récentes' et un wrapper responsive. Aucun n'est importé par l'application (vérifié par grep sur tout le dépôt).
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Aucune : composants non montés
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/groups, POST /api/tasks, PUT/PATCH /api/tasks/:id, GET /api/tasks | jamais (composants non importés) | server/routes.ts:3277 / 3340 (aucune route PATCH /api/tasks/:id n'existe) | TaskFormClean, SimpleTaskFormClean, TaskFormProduction et TaskFormUltraSimple appellent PATCH, qui renverrait une erreur. groupId 1 et createdBy 'admin' sont codés en dur. 2030 lignes au total. |
|
||||
|
||||
**Lisibilité / simplicité :** Sans effet sur l'utilisateur aujourd'hui. C'est de la dette : six formulaires divergents (alert() natif, PATCH inexistant, magasin 1 codé en dur), alors que le formulaire réellement utilisé est encore un septième, écrit en ligne dans Tasks.tsx, plus un huitième dans la page mobile.
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [TASKS-01](#tasks-01) | haute | perf-api | S | Chaque tâche embarque la ligne magasin complète (logo base64, config NocoDB/SMTP) | `server/storage.ts:2271` |
|
||||
| [TASKS-02](#tasks-02) | haute | bug | S | Un employé sur desktop ne voit jamais aucune tâche | `client/src/components/Layout.tsx:69` |
|
||||
| [TASKS-03](#tasks-03) | haute | perf-client | S | Rechargement complet de la page après chaque création ou modification | `client/src/pages/Tasks.tsx:120` |
|
||||
| [TASKS-07](#tasks-07) | haute | perf-api | S | Requête /api/users inutile (jamais utilisée), en N+1 côté serveur, qui expose les mots de passe hashés | `client/src/pages/Tasks.tsx:385` |
|
||||
| [TASKS-09](#tasks-09) | haute | bug | S | Sur tablette, la recherche et les filtres sont inaccessibles et le Kanban est vide | `client/src/pages/Tasks.tsx:697` |
|
||||
| [TASKS-13](#tasks-13) | haute | bug | S | Droits incohérents entre la matrice de permissions, l'interface et l'API (supprimer, terminer, modifier) | `server/routes.ts:3425` |
|
||||
| [TASKS-04](#tasks-04) | moyenne | perf-api | S | Utilisateur et magasins rechargés en base à chaque requête alors que req.user les contient déjà | `server/routes.ts:3195` |
|
||||
| [TASKS-05](#tasks-05) | moyenne | perf-client | S | Invalidation forcée au montage et au changement de magasin : 2 à 3 requêtes /api/tasks au lieu d'une | `client/src/pages/Tasks.tsx:299` |
|
||||
| [TASKS-06](#tasks-06) | moyenne | bug | S | Même queryKey ['/api/tasks', storeId] pour deux URL différentes (Dashboard et Tasks) | `client/src/pages/Dashboard.tsx:282` |
|
||||
| [TASKS-08](#tasks-08) | moyenne | perf-client | M | Chargement en cascade /api/user puis /api/tasks, avec un faux 'Aucune tâche' affiché pendant l'attente | `client/src/hooks/useAuthUnified.ts:146` |
|
||||
| [TASKS-10](#tasks-10) | moyenne | perf-client | M | Toute la liste est rendue deux fois (version tablette et version desktop montées, l'une cachée en CSS) | `client/src/pages/Tasks.tsx:851` |
|
||||
| [TASKS-11](#tasks-11) | moyenne | bug | S | Les filtres 'Aujourd'hui', 'Cette semaine' et 'En retard' incluent les tâches sans échéance | `client/src/pages/Tasks.tsx:491` |
|
||||
| [TASKS-12](#tasks-12) | moyenne | bug | S | Une tâche due aujourd'hui s'affiche 'En retard de 0 jour', une tâche due demain 'Dans 0 jour' | `client/src/pages/Tasks.tsx:580` |
|
||||
| [TASKS-14](#tasks-14) | moyenne | ux-simplicite | S | Compteurs 'Tâches en cours (n)' et 'Tâches terminées (n)' calculés sur la page courante seulement | `client/src/pages/Tasks.tsx:1189` |
|
||||
| [TASKS-15](#tasks-15) | moyenne | accessibilite | S | Actions en icônes seules sans libellé, infobulle ni aria-label | `client/src/pages/Tasks.tsx:1296` |
|
||||
| [TASKS-16](#tasks-16) | moyenne | accessibilite | S | Modales faites à la main au lieu du Dialog shadcn importé (pas d'Échap, pas de focus, pas de rôle) | `client/src/pages/Tasks.tsx:732` |
|
||||
| [TASKS-17](#tasks-17) | moyenne | coherence-design | M | Formulaire mêlant styles inline (angles carrés, fond blanc forcé) et classes Tailwind arrondies avec mode sombre | `client/src/pages/Tasks.tsx:135` |
|
||||
| [TASKS-18](#tasks-18) | moyenne | coherence-design | S | Couleurs de priorité, vocabulaire et icônes différents entre desktop et mobile | `client/src/pages/Tasks.tsx:544` |
|
||||
| [TASKS-19](#tasks-19) | moyenne | lisibilite | M | Cartes surchargées : jusqu'à 7 informations redondantes par tâche, avec le rôle technique affiché | `client/src/pages/Tasks.tsx:1212` |
|
||||
| [TASKS-20](#tasks-20) | moyenne | accessibilite | S | Tâches terminées quasi illisibles (opacité 60 % + gris 400 + texte barré) | `client/src/pages/Tasks.tsx:1350` |
|
||||
| [TASKS-21](#tasks-21) | moyenne | ux-simplicite | M | Formulaire : assignation en texte libre, magasin implicite (magasin 1 pour un admin) et aucune vérification des dates | `client/src/pages/Tasks.tsx:90` |
|
||||
| [TASKS-22](#tasks-22) | moyenne | ux-simplicite | S | Erreurs de chargement et absence de magasin affichées comme 'Aucune tâche trouvée avec les filtres sélectionnés' | `client/src/pages/Tasks.tsx:326` |
|
||||
| [TASKS-23](#tasks-23) | moyenne | perf-client | S | Spinner plein écran à chaque changement de magasin, faute de placeholderData et de squelette | `client/src/pages/Tasks.tsx:670` |
|
||||
| [TASKS-24](#tasks-24) | moyenne | perf-client | M | Terminer et Supprimer passent par des fetch manuels qui attendent le rechargement complet de la liste avant tout retour visuel | `client/src/pages/Tasks.tsx:405` |
|
||||
| [TASKS-25](#tasks-25) | moyenne | bug | S | Mobile : 'Tâche terminée' ou 'Tâche supprimée' affiché même quand l'API refuse ou échoue | `client/src/pages/mobile/TasksPage.tsx:334` |
|
||||
| [TASKS-26](#tasks-26) | moyenne | ux-simplicite | S | Mobile : tâches programmées non signalées et 'Terminer' proposé alors que le desktop l'interdit | `client/src/pages/mobile/TasksPage.tsx:215` |
|
||||
| [TASKS-27](#tasks-27) | moyenne | perf-api | M | GET /api/tasks sans limite : tout l'historique des tâches terminées (et tous les magasins pour un admin) à chaque chargement | `server/storage.ts:2301` |
|
||||
| [TASKS-28](#tasks-28) | moyenne | perf-serveur | S | console.log volumineux dans tous les handlers tâches (corps complets, parcours de toute la liste) | `server/routes.ts:3252` |
|
||||
| [TASKS-29](#tasks-29) | moyenne | perf-serveur | S | Index de la table tasks déclarés dans un fichier SQL jamais exécuté, et absents de schema.ts | `server/migrations.ts:112` |
|
||||
| [TASKS-31](#tasks-31) | moyenne | dette-code | L | Tasks.tsx (1633 lignes) duplique 6 fois le balisage des cartes et 2 fois les filtres, et le mobile réimplémente tout | `client/src/pages/Tasks.tsx:874` |
|
||||
| [TASKS-32](#tasks-32) | moyenne | dette-code | S | Huit composants de formulaire ou de carte de tâche et ResponsiveTasks jamais importés (2030 lignes), dont plusieurs appellent une route PATCH inexistante | `client/src/components/tasks/TaskFormUltraSimple.tsx:38` |
|
||||
| [TASKS-30](#tasks-30) | basse | perf-api | S | PUT, DELETE et complete : contrôle d'accès en lecture préalable avec jointure magasin complète, puis écriture (3 à 4 allers-retours) | `server/storage.ts:2348` |
|
||||
| [TASKS-33](#tasks-33) | basse | dette-code | S | console.log de debug dans le rendu et le queryFn, imports et variables inutilisés | `client/src/pages/Tasks.tsx:285` |
|
||||
| [TASKS-34](#tasks-34) | basse | perf-client | S | Filtrage et tri recalculés à chaque rendu sans useMemo, et sous-filtres répétés dans le JSX | `client/src/pages/Tasks.tsx:472` |
|
||||
| [TASKS-35](#tasks-35) | basse | perf-bundle | S | Pages Tâches desktop et mobile importées statiquement dans le bundle principal | `client/src/components/RouterProduction.tsx:24` |
|
||||
| [TASKS-36](#tasks-36) | basse | bug | S | Avec server/index.ts (dev et `npm start`), les apostrophes des titres de tâches sont stockées échappées en HTML (') | `server/security.ts:202` |
|
||||
|
||||
### TASKS-01
|
||||
|
||||
**Chaque tâche embarque la ligne magasin complète (logo base64, config NocoDB/SMTP)** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2271`
|
||||
- **Constat :** storage.ts:2271-2277 `db.select({ task: tasks, group: groups }).from(tasks).leftJoin(groups, ...)`. Or shared/schema.ts:67 `logo: text("logo"), // Logo en data URI (data:image/png;base64,...)` et l.53-76 (nocodb*, webhookUrl, smtp*). Côté client, task.group n'est lu nulle part, sauf dans un console.log (Tasks.tsx:364 `hasGroup: !!data[0].group`). En plus, stripSmtpPassword (routes.ts:168) parcourt récursivement chaque objet group.
|
||||
- **Impact :** Le logo du magasin (souvent des dizaines de Ko) est répété dans le JSON autant de fois qu'il y a de tâches. Pour 200 tâches, cela fait plusieurs Mo à télécharger et à parser, en desktop comme en mobile (et sur le Dashboard, qui appelle le même endpoint). Aucun gzip n'est actif : server/cache.ts:96 ne fait que poser l'en-tête.
|
||||
- **Recommandation :** Partie automatisable : projeter les colonnes dans getTasks et getTask (`group: { id: groups.id, name: groups.name, color: groups.color }`), en gardant la forme `{...task, group, isFutureTask}` renvoyée au client. Ajouter `compression` dans index.production.ts est une étape séparée : nouvelle dépendance npm, et il faut vérifier qu'aucun reverse proxy ne compresse déjà. Ne pas réutiliser setupCompression de cache.ts, qui est défectueux.
|
||||
|
||||
### TASKS-02
|
||||
|
||||
**Un employé sur desktop ne voit jamais aucune tâche** — bug, sévérité haute, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/components/Layout.tsx:69`
|
||||
- **Constat :** Layout.tsx:69 : la sélection automatique du magasin n'existe que pour `(user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && stores.length === 1`. Layout.tsx:131 : le sélecteur de magasin n'est affiché que pour `admin || directeur || manager`. Côté serveur, routes.ts:3237-3248, sans storeId pour un non-admin : `return res.json([]);`
|
||||
- **Impact :** Sans selectedStoreId déjà en localStorage, un employé arrive sur 'Aucune tâche trouvée avec les filtres sélectionnés' et n'a aucun moyen d'en sortir. Il ne voit pas les tâches qu'on lui a assignées.
|
||||
- **Recommandation :** Corriger côté client uniquement : dans Layout.tsx et MobileApp.tsx, auto-sélectionner l'unique magasin pour tout rôle non-admin (employee compris), et afficher le sélecteur aux employés qui ont plusieurs magasins. Garder le comportement serveur actuel et afficher un état vide explicite « Choisissez un magasin » quand selectedStoreId est null.
|
||||
|
||||
### TASKS-03
|
||||
|
||||
**Rechargement complet de la page après chaque création ou modification** — perf-client, sévérité haute, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:120`
|
||||
- **Constat :** Tasks.tsx:116-120 `toast({ title: "Succès", ... }); window.location.reload();`. Même chose dans mobile/TasksPage.tsx:83-87.
|
||||
- **Impact :** Chaque enregistrement recharge tout le bundle JS, refait les 4 ou 5 appels /api/user, /api/groups, /api/tasks, etc. L'écran devient blanc 1 à 3 s et le toast de succès disparaît immédiatement. La position de pagination, les filtres et la recherche sont perdus.
|
||||
- **Recommandation :** Dans TaskFormInline et MobileTaskForm, remplacer `window.location.reload()` par `await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); onClose();` (useQueryClient), puis réactiver le bouton en cas d'erreur, comme aujourd'hui. Pas de setQueryData à partir de la réponse du POST ou du PUT.
|
||||
|
||||
### TASKS-07
|
||||
|
||||
**Requête /api/users inutile (jamais utilisée), en N+1 côté serveur, qui expose les mots de passe hashés** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:385`
|
||||
- **Constat :** Tasks.tsx:385 `const { data: users = [] } = useQuery({ queryKey: ["/api/users"], ... enabled: !!user && (admin||manager||directeur) })`. `users` n'est utilisé nulle part dans le fichier (vérifié par grep). Côté serveur, routes.ts:4276-4282 `const baseUsers = await storage.getUsers(); ... baseUsers.map(async (baseUser) => { const userWithGroups = await storage.getUserWithGroups(baseUser.id);`, soit 2 requêtes par utilisateur. Et storage.ts:409 `db.select().from(users)` inclut la colonne password (schema.ts:40).
|
||||
- **Impact :** Chaque ouverture de la page Tâches par un manager lance 2N+3 requêtes SQL pour rien et envoie au navigateur la liste de tous les utilisateurs avec leurs hash de mot de passe.
|
||||
- **Recommandation :** Partie automatisable : supprimer la useQuery '/api/users' de Tasks.tsx (aucun lecteur). Retirer `password` de la réponse de GET /api/users est une correction de sécurité à part, à traiter hors du périmètre tâches. Remplacer la boucle N+1 par une jointure demande une réécriture manuelle.
|
||||
|
||||
### TASKS-09
|
||||
|
||||
**Sur tablette, la recherche et les filtres sont inaccessibles et le Kanban est vide** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:697`
|
||||
- **Constat :** Tasks.tsx:697 : le bouton Filtres a `className="sm:hidden ..."`, il est donc caché dès 640 px. l.761 `${filtersOpen ? 'block' : 'hidden'} lg:hidden` : filtersOpen ne peut jamais passer à true. l.852-1089 : la zone tablette `block lg:hidden` ne contient que `<TabsContent value="list">`, alors que l'onglet Kanban (l.712) reste cliquable. Le routeur envoie vers cette page toute largeur >= 768 px (RouterProduction.tsx:107, use-screen-size.ts mobile: 768).
|
||||
- **Impact :** Sur une tablette de magasin (iPad 768-1024 px), l'utilisateur ne peut ni chercher ni filtrer. S'il clique sur 'Kanban', l'écran devient vide.
|
||||
- **Recommandation :** Remplacer `sm:hidden` par `lg:hidden` sur le bouton Filtres (ou afficher les filtres en ligne au-dessus de la liste en dessous de lg), et ajouter le TabsContent kanban dans la zone tablette. Mieux : un seul rendu (voir TASKS-10).
|
||||
|
||||
### TASKS-13
|
||||
|
||||
**Droits incohérents entre la matrice de permissions, l'interface et l'API (supprimer, terminer, modifier)** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3425`
|
||||
- **Constat :** shared/permissions.ts:85-90 : `manager: ['view','create','edit','validate']` (pas de delete), `employee: ['view']`. Pourtant Tasks.tsx:668 `canEditTasks = admin||manager||directeur` affiche Supprimer aux managers. Tasks.tsx:1385-1394 : le bouton Supprimer des tâches terminées n'est conditionné par aucun droit (visible pour les employés). mobile/TasksPage.tsx:254 : 'Terminer' est affiché à tous (`{!isCompleted && (`). Côté serveur, PUT (routes.ts:3340), DELETE (3425) et complete (3455) ne vérifient que l'appartenance au magasin, jamais `hasPermission(user.role, 'tasks', ...)`. Seul le POST le fait (l.3285).
|
||||
- **Impact :** Un employé peut supprimer des tâches terminées depuis le desktop, terminer n'importe quelle tâche depuis le mobile et modifier des tâches via l'API. Un manager peut supprimer. Le comportement change selon l'appareil utilisé.
|
||||
- **Recommandation :** Côté serveur, ajouter `hasPermission(user.role,'tasks','edit'|'delete'|'validate')` dans PUT, DELETE et complete. Côté client, dériver canCreate, canEdit, canDelete et canComplete de `hasPermission` (shared/permissions.ts) dans les deux pages, et décider avec le métier si l'employé peut 'Terminer' (permission 'validate').
|
||||
|
||||
### TASKS-04
|
||||
|
||||
**Utilisateur et magasins rechargés en base à chaque requête alors que req.user les contient déjà** — perf-api, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:3195`
|
||||
- **Constat :** localAuth.production.ts:193-195 : deserializeUser fait déjà `await storage.getUserWithGroups(id)`. Chaque handler tâches le refait : routes.ts:3195, 3279, 3342, 3427, 3457 `const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);`. getUserWithGroups (storage.ts:371-388) enchaîne lui-même 2 requêtes : getUser puis user_groups JOIN groups.
|
||||
- **Impact :** 4 allers-retours SQL séquentiels avant la vraie requête, sur chaque appel tâches. Cela ajoute plusieurs dizaines de ms de latence à chaque chargement et à chaque clic Terminer ou Supprimer.
|
||||
- **Recommandation :** Dans les 5 handlers tâches, remplacer l'appel par `const user = req.user as UserWithGroups;` (la donnée est identique, chargée par localAuth.ts:141-144 pour la même requête). Ne pas mettre en cache l'utilisateur ni ses permissions. La fusion de getUser et user_groups en une seule requête est une optimisation distincte, à faire à la main.
|
||||
|
||||
### TASKS-05
|
||||
|
||||
**Invalidation forcée au montage et au changement de magasin : 2 à 3 requêtes /api/tasks au lieu d'une** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:299`
|
||||
- **Constat :** Tasks.tsx:299-308 `useEffect(() => { if (user && (directeur||manager) && selectedStoreId) { ... queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); } }, [selectedStoreId, user, queryClient]);`. La queryKey contient déjà selectedStoreId (l.327). Layout.tsx:138-146 invalide aussi '/api/tasks' (l'ancien magasin, encore actif) avant setSelectedStoreId.
|
||||
- **Impact :** Pour un directeur ou un manager, chaque visite ignore le cache encore frais (staleTime de 30 s) : la requête initiale est annulée (cancelRefetch par défaut) puis relancée. Un changement de magasin produit un refetch de l'ancien magasin, un fetch du nouveau et un fetch annulé puis relancé, chacun avec le gros payload décrit en TASKS-01.
|
||||
- **Recommandation :** Supprimer ce useEffect : le changement de queryKey suffit. Dans Layout, ne pas invalider les clés de l'ancien magasin (le changement de clé déclenche le fetch). Corriger d'abord la collision de clé avec le Dashboard (TASKS-06), qui est probablement à l'origine de ce contournement.
|
||||
|
||||
### TASKS-06
|
||||
|
||||
**Même queryKey ['/api/tasks', storeId] pour deux URL différentes (Dashboard et Tasks)** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Dashboard.tsx:282`
|
||||
- **Constat :** Dashboard.tsx:282-286 `queryKey: ["/api/tasks", selectedStoreId], ... if (selectedStoreId && user?.role === 'admin') params.append("storeId", ...)`. Pour un non-admin, l'appel part donc sans storeId et le serveur renvoie [] (routes.ts:3248). Tasks.tsx:327-333 utilise la même clé mais envoie toujours storeId.
|
||||
- **Impact :** Un directeur ou un manager qui passe du Dashboard aux Tâches dans les 30 s reçoit la liste vide mise en cache par le Dashboard. Le 'FORCE REFRESH' de Tasks.tsx:299 masque ce bug au prix de requêtes en double (TASKS-05).
|
||||
- **Recommandation :** Créer un hook partagé `useTasks(storeId)` (client/src/hooks/useTasks.ts) avec une seule queryFn qui envoie toujours storeId. L'utiliser dans Dashboard, mobile/DashboardPage, Tasks et mobile/TasksPage, puis supprimer l'invalidation forcée.
|
||||
|
||||
### TASKS-08
|
||||
|
||||
**Chargement en cascade /api/user puis /api/tasks, avec un faux 'Aucune tâche' affiché pendant l'attente** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/hooks/useAuthUnified.ts:146`
|
||||
- **Constat :** useAuthUnified.ts:23-24 et 146-217 : en production, chaque instance du hook a son propre useState et fait `fetch('/api/user', { cache: 'no-cache' })` au montage. Tasks.tsx:381 `enabled: !!user`. Quand la query est désactivée, isLoading vaut false, donc Tasks.tsx:670 ne montre pas le spinner et la page affiche '0 tâche trouvée' (l.689) puis 'Aucune tâche trouvée avec les filtres sélectionnés' (l.855-864 / 1176-1185).
|
||||
- **Impact :** Chaque visite paie un aller-retour /api/user supplémentaire (4 en desktop, 5 en mobile) avant que les tâches ne soient demandées. L'utilisateur voit brièvement 'Aucune tâche', ce qui peut l'inquiéter, puis la liste apparaît.
|
||||
- **Recommandation :** Faire de useAuthUnified une fine couche au-dessus d'une seule useQuery(['/api/user'], staleTime 5 min) partagée par tous les composants : le user est alors disponible immédiatement à la navigation. Dans Tasks, afficher le squelette tant que `!user || isPending`.
|
||||
|
||||
### TASKS-10
|
||||
|
||||
**Toute la liste est rendue deux fois (version tablette et version desktop montées, l'une cachée en CSS)** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:851`
|
||||
- **Constat :** Tasks.tsx:851 `<div className="block lg:hidden">` et l.1093 `<div className="hidden lg:flex gap-6">` : les deux sous-arbres (cartes, filtres, deux champs de recherche liés au même état) sont montés en permanence. Seul le CSS cache l'un des deux.
|
||||
- **Impact :** Deux fois plus de nœuds DOM et de calcul à chaque frappe dans la recherche. Les deux copies des cartes appellent format() et les helpers de date. Cela se ressent sur des PC de magasin modestes.
|
||||
- **Recommandation :** Utiliser useScreenSize() (déjà présent) pour ne rendre qu'une seule variante, ou mieux, un seul composant responsive <TaskCard> et un seul <TaskFilters>.
|
||||
|
||||
### TASKS-11
|
||||
|
||||
**Les filtres 'Aujourd'hui', 'Cette semaine' et 'En retard' incluent les tâches sans échéance** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:491`
|
||||
- **Constat :** Tasks.tsx:491-509 `if (dueDateFilter !== "all" && task.dueDate) { switch ... } else if (dueDateFilter === "no_due_date" && task.dueDate) { return false; }`. Une tâche avec dueDate null et le filtre 'overdue' passe les deux branches et arrive à `return true`. De plus, l.498 `isThisWeek(dueDate)` est appelé sans `{ weekStartsOn: 1 }`, donc la semaine commence le dimanche.
|
||||
- **Impact :** En choisissant 'En retard', le manager voit aussi toutes les tâches sans date, ce qui lui fait croire à du retard. 'Cette semaine' est décalé d'un jour par rapport à la semaine française.
|
||||
- **Recommandation :** Au début du bloc : `if (dueDateFilter !== 'all') { if (dueDateFilter === 'no_due_date') return !task.dueDate; if (!task.dueDate) return false; ... }`. Utiliser `isThisWeek(d, { weekStartsOn: 1 })`.
|
||||
|
||||
### TASKS-12
|
||||
|
||||
**Une tâche due aujourd'hui s'affiche 'En retard de 0 jour', une tâche due demain 'Dans 0 jour'** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:580`
|
||||
- **Constat :** schema.ts:312 `dueDate: date("due_date")` renvoie '2026-10-02'. `new Date('2026-10-02')` donne minuit UTC, soit 02:00 à Paris. Tasks.tsx:580 teste `if (isPast(due))` avant `else if (isToday(due))` (l.588) : dès 2 h du matin, la branche 'Aujourd'hui' est inatteignable et le libellé devient `En retard de ${Math.abs(daysDiff)} jour` avec daysDiff = 0. l.578 `differenceInDays(due, now)` tronque : demain 02:00 moins aujourd'hui 15:00 donne 0, d'où 'Dans 0 jour' / '0j'. Le filtre 'overdue' (l.501) range aussi les tâches du jour en retard. Mobile : TasksPage.tsx:203-210, même problème pour 'Dans 0j'.
|
||||
- **Impact :** Des badges rouges 'En retard' s'affichent à tort tous les jours et les libellés sont absurdes. Cela détruit la confiance dans l'indicateur principal de la page.
|
||||
- **Recommandation :** Parser la date en local avec `parseISO(task.dueDate)` (date-fns), qui donne minuit local, et comparer en jours calendaires : `differenceInCalendarDays(startOfDay(due), startOfDay(new Date()))`, < 0 pour en retard, 0 pour aujourd'hui, 1 pour demain. Mutualiser cette logique dans un helper partagé desktop et mobile.
|
||||
|
||||
### TASKS-14
|
||||
|
||||
**Compteurs 'Tâches en cours (n)' et 'Tâches terminées (n)' calculés sur la page courante seulement** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:1189`
|
||||
- **Constat :** Tasks.tsx:1189-1192 `paginatedTasks.filter(task => task.status === 'pending').length` (idem l.868-871, 1027-1030, 1337-1340). La pagination fait 10 éléments (l.542) sur une liste triée avec les tâches en cours d'abord, donc les terminées n'apparaissent qu'aux dernières pages. usePagination (pagination.tsx:157-159) revient à la page 1 dès que `data.length` change, par exemple après une suppression en page 3.
|
||||
- **Impact :** Le titre affiche 'Tâches en cours (10)' alors qu'il y en a 45. Le manager sous-estime la charge. Après chaque suppression, il est renvoyé en page 1.
|
||||
- **Recommandation :** Calculer les totaux sur filteredTasks (`pendingCount`, `completedCount`) et les afficher dans le titre ou dans des onglets 'À faire (45) / Terminées (120)'. Paginer chaque section séparément, ou n'afficher les terminées que sur demande. Ne réinitialiser la page que lorsque les filtres changent.
|
||||
|
||||
### TASKS-15
|
||||
|
||||
**Actions en icônes seules sans libellé, infobulle ni aria-label** — accessibilite, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:1296`
|
||||
- **Constat :** Tasks.tsx:1296-1325 `<Button size="sm" variant="outline" onClick={() => handleCompleteTask(task.id)} ...><CheckCircle className="w-4 h-4" /></Button>`, même chose pour Edit et Trash2. Kanban l.1471-1494 en h-7 (28 px). Mobile : TasksPage.tsx:267-272 (modifier, supprimer) et le FAB l.449-454 `<Plus />` sans texte.
|
||||
- **Impact :** Un employé non technicien doit deviner l'icône. Le coche vert 'Terminer' se confond facilement avec 'Modifier'. Les boutons Kanban de 28 px sont difficiles à viser sur écran tactile. Les lecteurs d'écran annoncent 'bouton' sans nom.
|
||||
- **Recommandation :** Partie automatisable : ajouter seulement `aria-label` et `title` ('Terminer la tâche', 'Modifier', 'Supprimer', 'Nouvelle tâche') sur les boutons icône desktop, Kanban et mobile (FAB compris). Libellés visibles, menu « ... » et hauteur de 36 à 44 px : à valider côté design.
|
||||
|
||||
### TASKS-16
|
||||
|
||||
**Modales faites à la main au lieu du Dialog shadcn importé (pas d'Échap, pas de focus, pas de rôle)** — accessibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:732`
|
||||
- **Constat :** Tasks.tsx:12 importe `Dialog, DialogContent, ...`, jamais utilisés. Les modales sont des `<div className="fixed inset-0 bg-black bg-opacity-50 z-[1000] ...">` (l.732, 1568, 1599) avec un bouton `✕` sans aria-label (l.738-744, 1574-1582). Mobile : TasksPage.tsx:96 et 469, même schéma.
|
||||
- **Impact :** Échap et le clic sur le fond ne ferment rien. Le focus clavier reste derrière la modale et le scroll de la page n'est pas bloqué. Le style diffère des autres pages qui utilisent Dialog.
|
||||
- **Recommandation :** Utiliser `<Dialog open onOpenChange>` + DialogContent/DialogHeader/DialogTitle pour la création et l'édition, AlertDialog pour la suppression (comme les autres pages), et Sheet (side="bottom") en mobile.
|
||||
|
||||
### TASKS-17
|
||||
|
||||
**Formulaire mêlant styles inline (angles carrés, fond blanc forcé) et classes Tailwind arrondies avec mode sombre** — coherence-design, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:135`
|
||||
- **Constat :** Tasks.tsx:135 `<div style={{ padding: '24px', backgroundColor: 'white' }}>`. Titre et description : l.155-161 `style={{ ... borderRadius: '0px', fontSize: '14px' }}`. Priorité et dates : l.194 `className="w-full p-3 border ... rounded-md text-base min-h-[44px] ... dark:bg-gray-800"`. Champs HTML natifs au lieu de Input, Select et Textarea shadcn. Bouton de soumission désactivé via `submitBtn.textContent = "..."` (l.70-72).
|
||||
- **Impact :** Dans une même modale, des champs carrés de 36 px côtoient des champs arrondis de 44 px, et le fond reste blanc en mode sombre. L'ensemble paraît bricolé et ne ressemble pas aux autres formulaires de l'application.
|
||||
- **Recommandation :** Réécrire le formulaire avec les composants UI existants (Input, Textarea, Select, Label, Button), react-hook-form + zod (insertTaskSchema) et un état isPending de useMutation pour le bouton.
|
||||
|
||||
### TASKS-18
|
||||
|
||||
**Couleurs de priorité, vocabulaire et icônes différents entre desktop et mobile** — coherence-design, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:544`
|
||||
- **Constat :** Desktop, Tasks.tsx:546-563 : high donne `destructive` (rouge plein), medium `default` (bleu primaire plein), low `secondary` (gris). Mobile, TasksPage.tsx:193-195 : rouge, jaune et vert pâles. Onglets mobiles l.404-420 '⏳ Actives / ✅ Faites / 📋 Toutes' contre desktop 'En cours / Terminées / Toutes'. Badges desktop avec icône lucide et emoji en double : l.934-935 `<startDateStatus.icon /> 📅 {startDateStatus.text}`, l.951-952 `<DueDateIcon /> ⏰`.
|
||||
- **Impact :** La priorité 'Moyenne' (bleu plein) attire davantage l'œil que 'Faible' et entre en concurrence avec le bouton principal. Un utilisateur qui passe du PC au téléphone ne retrouve ni les couleurs ni les mots.
|
||||
- **Recommandation :** Créer un module partagé `taskUi.ts` (libellés, couleurs, icônes de priorité et de statut) utilisé par les deux pages : Élevée rouge, Moyenne ambre, Faible gris ou vert. Un seul vocabulaire ('À faire' / 'Terminées'). Une seule icône par badge, sans emoji.
|
||||
|
||||
### TASKS-19
|
||||
|
||||
**Cartes surchargées : jusqu'à 7 informations redondantes par tâche, avec le rôle technique affiché** — lisibilite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:1212`
|
||||
- **Constat :** Pour une tâche programmée (l.1212-1294) : titre suivi de ' (Programmée)', badge priorité, badge 'Future', '📅 +3j' plus 'Démarre dans 3 jours (05/10/2026)', '⏰ ...', 'Assigné à: Non assigné', 'Créée: 02/10/2026 14:37', et l.1290-1293 `Visible en avance ({user?.role})` qui affiche par exemple 'Visible en avance (directeur)'. Pour une tâche déjà démarrée, l.625-632 affiche un badge '📅 Active' et le texte 'Démarrée (dd/MM/yyyy)' sur chaque carte. Kanban l.1439/1517 : `cursor-pointer` sans onClick.
|
||||
- **Impact :** L'œil ne trouve pas l'essentiel (quoi, pour quand, qui). La même notion 'programmée' apparaît trois fois, et 'Active' ou 'Créée à HH:mm' n'apportent rien au quotidien. Les cartes Kanban semblent cliquables mais ne réagissent pas.
|
||||
- **Recommandation :** Garder sur la carte : titre, échéance colorée (seulement si proche ou dépassée), priorité (seulement si Élevée), personne assignée (si renseignée). Pour les tâches programmées, un seul badge 'Visible le 05/10'. Mettre la description et la date de création dans le détail ou la modale. Retirer `cursor-pointer`, ou rendre la carte cliquable pour ouvrir l'édition.
|
||||
|
||||
### TASKS-20
|
||||
|
||||
**Tâches terminées quasi illisibles (opacité 60 % + gris 400 + texte barré)** — accessibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:1350`
|
||||
- **Constat :** Tasks.tsx:1350 `<Card key={task.id} className="opacity-60 bg-gray-50">`, l.1355 `text-gray-500 truncate line-through`, l.1365 `<p className="text-sm text-gray-400 mb-2 line-clamp-2 line-through">{task.description}</p>`. Tâches futures, l.1205-1212 : `opacity-75` + `italic text-gray-600`. Mobile, TasksPage.tsx:221 : `opacity-60`.
|
||||
- **Impact :** Contraste bien inférieur au minimum WCAG de 4,5:1 : la description d'une tâche terminée est pratiquement invisible sur les écrans de magasin. L'opacité appliquée aux boutons (l.997, 1311) donne l'impression qu'ils sont désactivés alors qu'ils fonctionnent.
|
||||
- **Recommandation :** Ne pas baisser l'opacité de toute la carte : un fond vert très pâle avec une coche 'Terminée' et un texte gray-600 non barré suffisent. Pour les tâches futures, un simple badge 'Programmée', sans italique ni opacité.
|
||||
|
||||
### TASKS-21
|
||||
|
||||
**Formulaire : assignation en texte libre, magasin implicite (magasin 1 pour un admin) et aucune vérification des dates** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:90`
|
||||
- **Constat :** Tasks.tsx:248-254 : `<input name="assignedTo" type="text" placeholder="Nom de la personne assignée">`. l.80 enregistre la chaîne 'Non assigné' si le champ est vide. l.90 `groupId = selectedStoreId ? parseInt(...) : 1` : en vue 'Tous les magasins', l'admin crée dans le magasin 1 sans aucune indication. Le serveur fait de même (routes.ts:3303-3305). Seul le titre est validé (l.60-67) : on peut saisir une échéance antérieure à la date de début. Le formulaire mobile (TasksPage.tsx:142-160) n'a pas de date de début.
|
||||
- **Impact :** Les fautes de frappe dans les noms empêchent toute recherche par personne. Des tâches peuvent être créées dans le mauvais magasin. Des incohérences de dates passent sans avertissement, et une tâche programmée ne peut pas être créée depuis un téléphone.
|
||||
- **Recommandation :** Proposer un champ assigné en liste (utilisateurs du magasin, via un endpoint léger) avec 'Non assigné' comme valeur vide (null). Afficher le magasin cible dans la modale et l'imposer quand selectedStoreId est null. Valider `dueDate >= startDate`. Ajouter la date de début au formulaire mobile.
|
||||
|
||||
### TASKS-22
|
||||
|
||||
**Erreurs de chargement et absence de magasin affichées comme 'Aucune tâche trouvée avec les filtres sélectionnés'** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:326`
|
||||
- **Constat :** Tasks.tsx:326 `const { data: tasks = [], isLoading, error } = useQuery(...)` : `error` n'est jamais rendu. l.855-864 et 1176-1185 affichent le même message vide dans tous les cas. Côté serveur, routes.ts:3273 `res.status(500).json([])` et l.3248 `return res.json([])` quand aucun magasin n'est sélectionné. Mobile, TasksPage.tsx:295 ne récupère même pas `error`.
|
||||
- **Impact :** En cas de panne réseau, d'erreur 500 ou de magasin non choisi, l'utilisateur pense qu'il n'a rien à faire et peut manquer des tâches urgentes.
|
||||
- **Recommandation :** Distinguer trois états : erreur ('Impossible de charger les tâches', bouton Réessayer → refetch), aucun magasin ('Choisissez un magasin en haut de l'écran') et liste vide réelle (avec un bouton 'Créer une tâche' si l'utilisateur en a le droit). Côté serveur, renvoyer `{ message }` avec le code 500, pas un tableau vide.
|
||||
|
||||
### TASKS-23
|
||||
|
||||
**Spinner plein écran à chaque changement de magasin, faute de placeholderData et de squelette** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:670`
|
||||
- **Constat :** Tasks.tsx:670-676 `if (isLoading) { return (<div className="flex-1 flex items-center justify-center"><div className="animate-spin ..."/></div>); }`. La queryKey (l.327) change avec selectedStoreId, sans `placeholderData: keepPreviousData`. Mobile, TasksPage.tsx:362-370 : même comportement.
|
||||
- **Impact :** Chaque changement de magasin fait disparaître en-tête, filtres et recherche au profit d'un spinner, puis tout réapparaît. La page clignote et paraît plus lente qu'elle ne l'est.
|
||||
- **Recommandation :** Ajouter `placeholderData: keepPreviousData` (TanStack v5) et un indicateur discret (isFetching) dans l'en-tête. Garder en-tête et filtres affichés et ne remplacer que la liste par 3 à 5 cartes squelette au premier chargement.
|
||||
|
||||
### TASKS-24
|
||||
|
||||
**Terminer et Supprimer passent par des fetch manuels qui attendent le rechargement complet de la liste avant tout retour visuel** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:405`
|
||||
- **Constat :** Tasks.tsx:405-424 `await fetch(`/api/tasks/${taskId}/complete`, ...); await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); toast(...)`. l.440-460 : la modale de suppression ne se ferme qu'après `await queryClient.invalidateQueries(...)`. Aucun état pending, donc les doubles clics sont possibles. Le serveur ne renvoie pas la tâche mise à jour (routes.ts:3478 `res.json({ message: "Task completed successfully" })`).
|
||||
- **Impact :** Après un clic sur Terminer, rien ne bouge pendant tout le refetch du gros payload (TASKS-01). L'utilisateur reclique, ce qui envoie plusieurs requêtes, et la modale de suppression reste ouverte plusieurs centaines de ms.
|
||||
- **Recommandation :** Utiliser useMutation avec mise à jour optimiste (`onMutate` : setQueryData pour passer la tâche en completed ou la retirer, rollback en onError), boutons désactivés pendant `isPending`, et fermeture de la modale immédiatement. Côté serveur, renvoyer la tâche mise à jour (`.returning()`) depuis completeTask.
|
||||
|
||||
### TASKS-25
|
||||
|
||||
**Mobile : 'Tâche terminée' ou 'Tâche supprimée' affiché même quand l'API refuse ou échoue** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/TasksPage.tsx:334`
|
||||
- **Constat :** TasksPage.tsx:334-341 `await fetch(`/api/tasks/${taskId}/complete`, {...}); queryClient.invalidateQueries(...); toast({ title: "✅ Tâche terminée" });`, sans vérifier `response.ok`. l.350-352, même chose pour DELETE. fetch ne lève pas d'erreur sur 403 ou 500, donc le catch n'est jamais atteint.
|
||||
- **Impact :** Un employé sans droit (403), ou n'importe qui lors d'une erreur serveur, voit un message de succès alors que rien n'a changé, ce qui le désoriente.
|
||||
- **Recommandation :** Vérifier `if (!res.ok) throw new Error(...)`, ou utiliser apiRequest (queryClient.ts:10), qui lève déjà une erreur sur les statuts non-OK, et afficher le message d'erreur du serveur.
|
||||
|
||||
### TASKS-26
|
||||
|
||||
**Mobile : tâches programmées non signalées et 'Terminer' proposé alors que le desktop l'interdit** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/TasksPage.tsx:215`
|
||||
- **Constat :** La carte TaskCard (TasksPage.tsx:178-279) n'utilise jamais `task.isFutureTask` ni `startDate`. Pour un directeur ou un admin, getTasks renvoie les tâches futures (storage.ts:2295). Desktop : Tasks.tsx:980 `{canEditTasks && !isFuture && (` masque Terminer pour une tâche future. Mobile : l.254 `{!isCompleted && (` l'affiche.
|
||||
- **Impact :** Sur téléphone, un directeur voit les tâches de la semaine prochaine mêlées à celles du jour et peut les clôturer avant qu'elles ne soient visibles des équipes.
|
||||
- **Recommandation :** Ajouter un badge 'Programmée le dd/MM' et masquer ou désactiver 'Terminer' pour `isFutureTask`, via le helper partagé de TASKS-18 et TASKS-31.
|
||||
|
||||
### TASKS-27
|
||||
|
||||
**GET /api/tasks sans limite : tout l'historique des tâches terminées (et tous les magasins pour un admin) à chaque chargement** — perf-api, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2301`
|
||||
- **Constat :** storage.ts:2301 `const results = await query.orderBy(desc(tasks.createdAt));`, sans limit ni filtre de statut ou de date. routes.ts:3205 `groupIds = storeId ? [...] : undefined`, donc un admin en 'Tous les magasins' reçoit toutes les tâches de la base. La pagination est faite côté client (Tasks.tsx:534-542, 10 par page). Le mobile n'en a aucune et rend toute la liste (TasksPage.tsx:434).
|
||||
- **Impact :** Le volume grandit indéfiniment, car les tâches terminées ne sont jamais purgées. Combiné à TASKS-01, chaque visite télécharge des Mo pour en afficher 10 lignes.
|
||||
- **Recommandation :** Ajouter des paramètres `status` et `completedSince` (par défaut, tâches en cours + terminées des 30 derniers jours) ou une pagination serveur (`limit`/`offset` + total). Charger les terminées plus anciennes à la demande ('Voir l'historique').
|
||||
|
||||
### TASKS-28
|
||||
|
||||
**console.log volumineux dans tous les handlers tâches (corps complets, parcours de toute la liste)** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3252`
|
||||
- **Constat :** routes.ts:3252-3269 : à chaque GET, `console.log('🔍 Tasks API called with:', ...)` puis `taskGroups: tasks.map(t => ({ id: t.id, title: t.title, groupId: t.groupId })).slice(0, 3)` (map sur toutes les tâches avant le slice). Plus les l.3206, 3220, 3228 et 3242. POST l.3294-3300 `originalBody: req.body, processedData: data`. PUT l.3354-3365 et 3391-3399. storage.ts:2303-2313, 2366-2371, 2382-2387, 2393-2399, 2436-2455. Le serveur de production (index.production.ts) ne neutralise pas console.log.
|
||||
- **Impact :** Des E/S synchrones sur stdout à chaque requête, des logs Docker qui gonflent (descriptions complètes des tâches) et une allocation O(n) inutile sur le chemin le plus fréquent.
|
||||
- **Recommandation :** Supprimer ces logs, ou les placer derrière `if (process.env.DEBUG_TASKS)`. Garder seulement les console.error dans les catch, sans réexposer req.body.
|
||||
|
||||
### TASKS-29
|
||||
|
||||
**Index de la table tasks déclarés dans un fichier SQL jamais exécuté, et absents de schema.ts** — perf-serveur, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/migrations.ts:112`
|
||||
- **Constat :** migrations/20260814_add_performance_indexes.sql:62-65 `CREATE INDEX IF NOT EXISTS idx_tasks_group_id ON tasks (group_id); ... idx_tasks_group_status ...`. Mais server/migrations.ts:112-114 indique `// Ignorer les fichiers de migrations ... const allMigrations = hardcodedMigrations;`, et ce fichier ne figure pas dans la liste en dur (l.40-89). shared/schema.ts:307-322 ne déclare aucun index sur tasks. Rien ne couvre non plus `ORDER BY created_at DESC` filtré par group_id.
|
||||
- **Impact :** Selon l'environnement, getTasks fait un parcours complet de la table plus un tri, à chaque appel du Dashboard comme des Tâches. Le coût grandit avec l'historique.
|
||||
- **Recommandation :** Ajouter `CREATE INDEX IF NOT EXISTS idx_tasks_group_created ON tasks (group_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_tasks_group_status ON tasks (group_id, status);` dans server/migrations.production.ts (runProductionMigrations), et déclarer les index dans shared/schema.ts (3e argument de pgTable) pour `drizzle-kit push` en dev. À décider : soit supprimer migrations.ts et migrate.js, qui sont morts, soit les rebrancher. Ce DDL s'exécute au démarrage sur la base de production : à valider avant de l'appliquer.
|
||||
|
||||
### TASKS-31
|
||||
|
||||
**Tasks.tsx (1633 lignes) duplique 6 fois le balisage des cartes et 2 fois les filtres, et le mobile réimplémente tout** — dette-code, sévérité moyenne, effort L
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:874`
|
||||
- **Constat :** Cartes en cours : l.874-1021 (tablette), 1195-1331 (desktop), 1432-1501 (Kanban). Cartes terminées : l.1033-1068, 1343-1399, 1512-1556. Filtres : l.777-846 et 1100-1165. Mobile : TasksPage.tsx a son propre formulaire (l.36-175), sa propre config de priorité (l.191-198) et son propre filtre, avec une recherche limitée au titre (l.318) alors que le desktop cherche aussi dans la description (Tasks.tsx:475-476). Les actions sur les tâches terminées divergent : aucune sur tablette (l.1039-1066), Supprimer seul en liste desktop (l.1385-1394), Modifier + Supprimer en Kanban (l.1532-1551), aucun 'Rouvrir' nulle part. La date affichée est updatedAt sur tablette (l.1062) et completedAt ailleurs (l.1377).
|
||||
- **Impact :** Chaque correction doit être faite 3 à 6 fois, d'où les bugs ci-dessus qui n'existent que dans une variante. L'utilisateur trouve des actions différentes selon la vue ou l'appareil.
|
||||
- **Recommandation :** Extraire `useTasks` (query + mutations), `taskUi.ts` (helpers de date et de priorité), `<TaskCard variant>`, `<TaskFilters>` et `<TaskFormDialog>`, utilisés par le desktop et le mobile. Définir un seul jeu d'actions par statut (À faire : Terminer, Modifier, Supprimer ; Terminée : Rouvrir, Supprimer) et toujours afficher completedAt.
|
||||
|
||||
### TASKS-32
|
||||
|
||||
**Huit composants de formulaire ou de carte de tâche et ResponsiveTasks jamais importés (2030 lignes), dont plusieurs appellent une route PATCH inexistante** — dette-code, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/tasks/TaskFormUltraSimple.tsx:38`
|
||||
- **Constat :** Un grep sur tout le dépôt ne trouve aucun import de NewTaskForm, TaskFormClean, SimpleTaskFormClean, TaskFormProduction, TaskFormUltraSimple, TaskForm, SimpleTaskForm, RecentTasksCard ni ResponsiveTasks (RouterProduction.tsx:24/39 importe directement Tasks et MobileTasksPage). TaskFormUltraSimple.tsx:38 `const method = task ? 'PATCH' : 'POST';` et l.41-42 `groupId = 1; createdBy = 'admin'`. TaskFormClean.tsx:136 et SimpleTaskFormClean.tsx:137 utilisent aussi `"PATCH"`, alors que le serveur n'expose que `app.put('/api/tasks/:id'` (routes.ts:3340). Tasks.tsx:35 commente : `// TaskForm inline pour éviter les problèmes d'import en production`.
|
||||
- **Impact :** Brouille la lecture : on ne sait pas quel formulaire est le bon. Si quelqu'un en rebranche un, les modifications échoueront (PATCH en 404) et les tâches seront créées dans le magasin 1.
|
||||
- **Recommandation :** Supprimer client/src/components/tasks/{NewTaskForm,TaskFormClean,SimpleTaskFormClean,TaskFormProduction,TaskFormUltraSimple,TaskForm,SimpleTaskForm,RecentTasksCard}.tsx et client/src/components/ResponsiveTasks.tsx. Recréer ensuite un seul TaskFormDialog (TASKS-31).
|
||||
|
||||
### TASKS-30
|
||||
|
||||
**PUT, DELETE et complete : contrôle d'accès en lecture préalable avec jointure magasin complète, puis écriture (3 à 4 allers-retours)** — perf-api, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/storage.ts:2348`
|
||||
- **Constat :** routes.ts:3348, 3433 et 3463 `const task = await storage.getTask(id);` seulement pour lire task.groupId. storage.ts:2349-2356 `select({ task: tasks, group: groups })...leftJoin(groups, ...)` charge logo et config du magasin. Ensuite updateTask, deleteTask ou completeTask font une requête séparée.
|
||||
- **Impact :** Latence ajoutée à chaque action utilisateur, et le logo du magasin est lu inutilement à chaque clic.
|
||||
- **Recommandation :** Partie automatisable : supprimer la jointure groups dans DatabaseStorage.getTask (`db.select().from(tasks).where(eq(tasks.id, id))`), car ses seuls appelants (routes.ts:3348, 3433, 3463) n'utilisent pas `group`. La requête conditionnelle unique est à reporter au chantier des permissions (TASKS-13).
|
||||
|
||||
### TASKS-33
|
||||
|
||||
**console.log de debug dans le rendu et le queryFn, imports et variables inutilisés** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:285`
|
||||
- **Constat :** Tasks.tsx:285-296 `const isQueryEnabled = ...; console.log('🔍 TASK QUERY ENABLEMENT DEBUG:', {... timestamp: new Date().toISOString() })`, exécuté à chaque rendu et à chaque frappe. isQueryEnabled n'est utilisé nulle part (la query utilise `enabled: !!user`). Autres logs l.301, 335-344, 356-366, 94-100 et 108. Imports inutilisés : l.12 `Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger`. storeInitialized n'est lu que dans des logs.
|
||||
- **Impact :** Retirés du bundle Docker grâce à esbuild.pure (vite.config.ts:29), mais ils polluent la console en dev et en `npm start`, et nuisent à la lisibilité du code. console.error et console.warn restent actifs en production.
|
||||
- **Recommandation :** Supprimer seulement les logs, la variable isQueryEnabled, la lecture de storeInitialized et les imports Dialog. Ne pas brancher isQueryEnabled sur `enabled` dans la même passe : cela changerait le comportement de chargement selon le rôle, et relève de TASKS-02/05.
|
||||
|
||||
### TASKS-34
|
||||
|
||||
**Filtrage et tri recalculés à chaque rendu sans useMemo, et sous-filtres répétés dans le JSX** — perf-client, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Tasks.tsx:472`
|
||||
- **Constat :** Tasks.tsx:472-531 `const filteredTasks = tasks.filter(...).sort(...)` dans le corps du composant, avec `searchTerm.toLowerCase()` recalculé pour chaque tâche et `new Date(...)` dans le comparateur du tri. l.868, 871 et 874 : `paginatedTasks.filter(task => task.status === 'pending')` évalué 3 fois par section, deux fois (tablette et desktop). Kanban l.1429-1432 et 1509-1512 : même chose sur filteredTasks. Mobile, TasksPage.tsx:311-329 : pas de useMemo non plus.
|
||||
- **Impact :** Avec quelques centaines de tâches (TASKS-27), chaque frappe dans la recherche refait N filtres, un tri en N log N et 12 parcours de liste. Le champ de recherche devient perceptiblement lent sur les PC de magasin.
|
||||
- **Recommandation :** Envelopper le filtrage et le tri dans `useMemo` (dépendances : tasks, searchTerm, filtres), précalculer `pendingTasks`/`completedTasks` une seule fois, et utiliser `useDeferredValue(searchTerm)` pour la recherche.
|
||||
|
||||
### TASKS-35
|
||||
|
||||
**Pages Tâches desktop et mobile importées statiquement dans le bundle principal** — perf-bundle, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:24`
|
||||
- **Constat :** RouterProduction.tsx:24 `import Tasks from "@/pages/Tasks";` et l.39 `import MobileTasksPage from "@/pages/mobile/TasksPage";`, sans React.lazy. Les deux variantes, ainsi que toutes les autres pages (l.5-44), partent dans le chunk initial, même si un appareil n'en utilise qu'une.
|
||||
- **Impact :** Le premier chargement télécharge et parse le code des deux versions (environ 2100 lignes rien que pour les tâches) et de toutes les autres pages, ce qui retarde l'affichage du tableau de bord sur mobile.
|
||||
- **Recommandation :** Passer les pages en lazy avec un `<Suspense fallback={<PageSkeleton/>}>` et un ErrorBoundary qui recharge la page en cas d'échec de chargement d'un chunk. À tester sur un redéploiement réel, à la main et pour toutes les routes à la fois.
|
||||
|
||||
### TASKS-36
|
||||
|
||||
**Avec server/index.ts (dev et `npm start`), les apostrophes des titres de tâches sont stockées échappées en HTML (')** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/security.ts:202`
|
||||
- **Constat :** server/index.ts:37 `setupInputSanitization(app);`, puis security.ts:310 `req.body = sanitizeInput(req.body);`, puis l.202 `sanitized = validator.escape(sanitized);`. Un titre comme « Ranger l'entrepôt » est stocké « Ranger l'entrepôt », et React l'affiche tel quel puisqu'il échappe le texte. Le Dockerfile (l.41) compile index.production.ts, qui n'appelle pas ce middleware. En revanche, package.json:9 `start` lance dist/server/index.js, compilé depuis index.ts.
|
||||
- **Impact :** Selon le mode de démarrage, les titres et descriptions en français (très riches en apostrophes et guillemets) s'affichent avec des entités HTML, et la recherche sur « l'entrepôt » ne trouve plus rien.
|
||||
- **Recommandation :** Ne pas échapper en HTML à l'entrée (React protège déjà à l'affichage). Limiter le middleware à la suppression des octets nuls, ou l'appliquer seulement aux champs destinés à du HTML (e-mails). Aligner le comportement de index.ts et de index.production.ts.
|
||||
@@ -0,0 +1,441 @@
|
||||
# SAV
|
||||
|
||||
_38 constats vérifiés — 8 haute, 17 moyenne, 13 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/sav (desktop)`
|
||||
|
||||
**Rôle :** Suivre les retours SAV (produits défectueux, pièces manquantes...) d'un magasin : créer un ticket, suivre son statut et sa priorité, échanger des commentaires, supprimer un ticket.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Créer un ticket SAV (produit, fournisseur, client, problème, priorité)
|
||||
- Retrouver un ticket (recherche texte + filtres statut/priorité/fournisseur)
|
||||
- Ouvrir le suivi d'un ticket et changer son statut ou sa priorité
|
||||
- Ajouter un commentaire de suivi
|
||||
- Modifier un ticket (bouton crayon)
|
||||
- Supprimer un ticket (admin/directeur)
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/sav/tickets?status=&priority=&supplierId=&groupId= | au montage puis à chaque changement de filtre statut/priorité/fournisseur (nouvelle queryKey) | server/routes.ts:5242 -> storage.getUserWithGroups (2 requêtes) + storage.getSavTickets (server/storage.ts:2656) | Aucune pagination/LIMIT ; SELECT de lignes complètes savTickets + suppliers + groups (dont logo base64, config SMTP/NocoDB) + users (dont hash du mot de passe) ; Promise.all de getSavTicketHistory par ticket (no-op actuellement) ; 3 console.log par appel. |
|
||||
| GET /api/sav/stats?groupId= | au montage (queryFn fetch manuel, staleTime 2 min) | server/routes.ts:5524 -> storage.getUserWithGroups + storage.getSavTicketStats (server/storage.ts:2818) | 3 requêtes SQL séquentielles (group by status, group by priority, count) + variable baseQuery morte ; 'Critiques' compte aussi les tickets résolus/fermés. |
|
||||
| GET /api/suppliers | au montage (staleTime 10 min) | server/routes.ts:1141 -> storage.getUser + storage.getSuppliers (server/storage.ts:467) | Liste complète de tous les fournisseurs pour 2 Select sans recherche (filtre + formulaire). |
|
||||
| GET /api/groups | au montage (même clé que Layout.tsx:49, donc dédupliqué) | server/routes.ts:970 -> storage.getUserWithGroups + storage.getGroups (server/storage.ts:432) | Utilisé uniquement pour le formulaire de création ; StoreContext expose déjà `stores`. |
|
||||
| GET /api/sav/tickets/:id | ouverture de la modale de suivi (staleTime 0, déclaré 2 fois lignes 142 et 376) | server/routes.ts:5288 -> storage.getUserWithGroups + storage.getSavTicket (server/storage.ts:2720) | Renvoie les mêmes données que la liste (history toujours []), donc requête redondante à chaque ouverture. |
|
||||
| POST /api/sav/tickets | clic 'Créer le ticket' | server/routes.ts:5319 -> getUserWithGroups + getGroups (admin) + storage.createSavTicket (server/storage.ts:2747) | Numéro de ticket généré dans la route puis écrasé par un count(*) annuel dans storage (doublons possibles). |
|
||||
| PATCH /api/sav/tickets/:id | changement de Statut ou Priorité dans 'Actions Rapides' (sauvegarde immédiate) | server/routes.ts:5390 -> getUserWithGroups + getSavTicket (3 jointures) + storage.updateSavTicket (server/storage.ts:2773) + addSavTicketHistory (no-op) | resolvedAt/closedAt jamais renseignés ; changement de statut non historisé. |
|
||||
| POST /api/sav/tickets/:id/history | clic 'Ajouter commentaire' | server/routes.ts:5471 -> getUserWithGroups + getSavTicket + storage.addSavTicketHistory (server/storage.ts:2808, renvoie null sans rien écrire) | Le commentaire est perdu alors que l'UI affiche 'Commentaire ajouté'. |
|
||||
| DELETE /api/sav/tickets/:id | confirmation dans la modale de suppression | server/routes.ts:5443 -> storage.getUser + getSavTicket + storage.deleteSavTicket (server/storage.ts:2783) | Aucun contrôle d'appartenance au magasin pour un directeur. |
|
||||
|
||||
**Lisibilité / simplicité :** Page lisible dans sa structure (en-tête, 5 compteurs, filtres, grille de cartes) mais plusieurs fonctions visibles ne marchent pas : le bouton crayon ouvre un formulaire complet dont 'Sauvegarder' affiche 'Fonction en développement', les commentaires affichent un succès mais ne sont jamais enregistrés (historique toujours vide), et la liste ne se rafraîchit pas après création/suppression dès qu'un filtre ou un magasin est sélectionné (staleTime 5 min). Des codes techniques sont affichés tels quels ('pieces_manquantes', 'status_change', 'Statut changé de "en_cours" vers "resolu"'), avec 'N/A' et des emojis. Les cartes ont un curseur 'main' mais ne sont pas cliquables ; les actions sont 3 petites icônes sans libellé. La modale de détail (max-w-6xl, 5 cartes à gauche, overflow-hidden) coupe le bloc 'Actions Rapides' sur des écrans de portable. Le badge 'Nouveau' (commentaire récent) se confond avec le statut 'Nouveau', et les couleurs priorité/statut se chevauchent. Les tickets fermés restent mélangés aux tickets ouverts, sans pagination. Les états d'erreur ne sont pas gérés : une panne API affiche 'Aucun ticket trouvé'. La console est inondée de logs à chaque frappe.
|
||||
|
||||
### `/sav (mobile)`
|
||||
|
||||
**Rôle :** Version téléphone du SAV : consulter les tickets du magasin, appeler le client, changer rapidement le statut et créer un ticket depuis la surface de vente.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Rechercher un ticket (n°, client, produit)
|
||||
- Appeler le client (bouton 'Appeler')
|
||||
- Changer le statut via le menu ⋮
|
||||
- Créer un ticket via le bouton flottant +
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/sav-tickets?storeId= | au montage (enabled si selectedStoreId) | AUCUN handler (seul /api/sav/tickets existe, server/routes.ts:5242) -> 404 Express en production (index.production.ts:157-160 fait next() pour /api/) | La liste est toujours vide : l'erreur est masquée par l'état vide 'Aucun ticket SAV'. |
|
||||
| GET /api/suppliers | au montage (queryFn apiRequest, staleTime par défaut 30 s) | server/routes.ts:1141 -> storage.getSuppliers | Nécessaire uniquement pour le formulaire de création. |
|
||||
| GET /api/groups | au montage | server/routes.ts:970 -> storage.getGroups / userGroups | Utilisé seulement en repli dans onSubmit (admin sans magasin). |
|
||||
| PATCH /api/sav-tickets/:id/status | clic sur un statut dans le menu ⋮ | AUCUN handler | Valeurs envoyées en anglais ('in_progress', 'waiting_parts', 'resolved', 'closed') hors de l'enum serveur ; pas de onError. |
|
||||
| POST /api/sav-tickets | submit du formulaire 'Créer le ticket' | AUCUN handler | En production : toast d'erreur contenant le HTML brut du 404. |
|
||||
|
||||
**Lisibilité / simplicité :** Interface mobile simple et bien pensée (cartes compactes, bouton 'Appeler', bouton flottant), mais entièrement non fonctionnelle : elle appelle des endpoints /api/sav-tickets inexistants, donc la liste est toujours vide et la création comme le changement de statut échouent. Les erreurs de validation du formulaire ne s'affichent jamais (FormMessage absent). Le bouton + est visible pour les employés alors que le serveur refuse la création. Il n'y a ni vue détail, ni commentaires, ni confirmation avant 'Fermer'. Les libellés et couleurs divergent du desktop (badge 'Pièces' au lieu de 'Attente pièces', 'attente_echange' affiché en code brut, violet au lieu de bleu), avec du jargon ('Gencode (EAN13)', placeholder 'Scanner...' sans scanner).
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [MSAV-01](#msav-01) | haute | bug | S | La page mobile appelle des endpoints inexistants (/api/sav-tickets) : SAV mobile inutilisable | `client/src/pages/mobile/SavPage.tsx:88` |
|
||||
| [MSAV-02](#msav-02) | haute | bug | S | Valeurs de statut en anglais, hors de l'enum serveur | `client/src/pages/mobile/SavPage.tsx:230` |
|
||||
| [SAV-01](#sav-01) | haute | bug | S | La liste ne se rafraîchit pas après création/modification/suppression (queryKey incompatible avec l'invalidation) | `client/src/pages/SavTickets.tsx:98` |
|
||||
| [SAV-02](#sav-02) | haute | bug | M | Les commentaires et l'historique ne sont jamais enregistrés, mais l'UI affiche un succès | `server/storage.ts:2808` |
|
||||
| [SAV-03](#sav-03) | haute | ux-simplicite | S | Bouton 'Modifier' : formulaire complet dont la sauvegarde n'est pas implémentée | `client/src/pages/SavTickets.tsx:1338` |
|
||||
| [SAV-04](#sav-04) | haute | bug | S | Le hash du mot de passe du créateur est envoyé au navigateur pour chaque ticket | `server/storage.ts:2669` |
|
||||
| [SAV-05](#sav-05) | haute | perf-api | S | Chaque ticket embarque la ligne magasin complète, logo base64 et config SMTP/NocoDB compris | `server/storage.ts:2668` |
|
||||
| [SAV-06](#sav-06) | haute | bug | S | Un utilisateur non admin sans magasin assigné voit les tickets de tous les magasins | `server/routes.ts:5272` |
|
||||
| [MSAV-03](#msav-03) | moyenne | ux-simplicite | S | Les erreurs de validation du formulaire mobile ne s'affichent jamais | `client/src/pages/mobile/SavPage.tsx:283` |
|
||||
| [MSAV-04](#msav-04) | moyenne | ux-simplicite | S | Bouton + et menu de statut visibles pour les employés, qui n'ont pas le droit | `client/src/pages/mobile/SavPage.tsx:397` |
|
||||
| [MSAV-05](#msav-05) | moyenne | ux-simplicite | M | Mobile : ni détail, ni commentaires, ni confirmation de fermeture, ni gestion d'erreur du statut | `client/src/pages/mobile/SavPage.tsx:106` |
|
||||
| [SAV-07](#sav-07) | moyenne | bug | S | Création par un admin : ticket rattaché au premier magasin alphabétique et non au magasin sélectionné | `client/src/pages/SavTickets.tsx:337` |
|
||||
| [SAV-08](#sav-08) | moyenne | bug | S | Le formulaire 'Nouveau Ticket' est pré-rempli avec le dernier ticket ouvert en modification | `client/src/pages/SavTickets.tsx:400` |
|
||||
| [SAV-09](#sav-09) | moyenne | bug | S | DELETE : aucun contrôle d'appartenance au magasin | `server/routes.ts:5446` |
|
||||
| [SAV-10](#sav-10) | moyenne | perf-serveur | S | Chaque route SAV recharge l'utilisateur et ses magasins déjà fournis par la session | `server/routes.ts:5245` |
|
||||
| [SAV-11](#sav-11) | moyenne | perf-serveur | S | Statistiques SAV : 3 requêtes séquentielles au lieu d'une seule | `server/storage.ts:2832` |
|
||||
| [SAV-12](#sav-12) | moyenne | perf-serveur | M | Schéma N+1 : un appel getSavTicketHistory par ticket de la liste | `server/storage.ts:2704` |
|
||||
| [SAV-13](#sav-13) | moyenne | perf-api | M | Liste sans pagination ni limite, rendue entièrement en cartes | `server/storage.ts:2701` |
|
||||
| [SAV-14](#sav-14) | moyenne | perf-client | S | Chaque changement de filtre vide la liste et affiche un spinner (aucun placeholderData) | `client/src/pages/SavTickets.tsx:97` |
|
||||
| [SAV-15](#sav-15) | moyenne | dette-code | S | console.log de debug exécutés à chaque rendu (chaque frappe clavier) | `client/src/pages/SavTickets.tsx:294` |
|
||||
| [SAV-18](#sav-18) | moyenne | lisibilite | S | Codes techniques, anglais et emojis affichés à l'utilisateur | `client/src/pages/SavTickets.tsx:1034` |
|
||||
| [SAV-19](#sav-19) | moyenne | ux-simplicite | S | Modale de suivi trop chargée : 'Actions Rapides' coupé en bas sur écran de portable | `client/src/pages/SavTickets.tsx:966` |
|
||||
| [SAV-20](#sav-20) | moyenne | accessibilite | S | Cartes non cliquables malgré cursor-pointer ; actions en icônes minuscules sans libellé | `client/src/pages/SavTickets.tsx:846` |
|
||||
| [SAV-22](#sav-22) | moyenne | ux-simplicite | S | Compteurs trompeurs et non cliquables (Critiques inclut les tickets clos, Résolus inclut Fermés) | `server/storage.ts:2882` |
|
||||
| [SAV-23](#sav-23) | moyenne | ux-simplicite | S | Pas d'état d'erreur, état vide sans action, et compteurs qui apparaissent en décalant la page | `client/src/pages/SavTickets.tsx:832` |
|
||||
| [MSAV-06](#msav-06) | basse | coherence-design | S | Libellés et couleurs de statut différents du desktop ; statut 'attente_echange' affiché brut | `client/src/pages/mobile/SavPage.tsx:165` |
|
||||
| [MSAV-07](#msav-07) | basse | lisibilite | S | Jargon 'Gencode (EAN13)' et placeholder 'Scanner...' sans scanner | `client/src/pages/mobile/SavPage.tsx:284` |
|
||||
| [MSAV-08](#msav-08) | basse | dette-code | S | Imports inutilisés et requêtes chargées au montage pour un formulaire fermé | `client/src/pages/mobile/SavPage.tsx:100` |
|
||||
| [MSAV-09](#msav-09) | basse | ux-simplicite | S | Erreur ou absence de magasin sélectionné affichée comme 'Aucun ticket SAV' | `client/src/pages/mobile/SavPage.tsx:202` |
|
||||
| [SAV-16](#sav-16) | basse | perf-serveur | S | Logs console sur chaque requête GET SAV (chemin chaud) | `server/routes.ts:5258` |
|
||||
| [SAV-17](#sav-17) | basse | dette-code | S | Requête de détail déclarée deux fois, plus fetch manuels incohérents | `client/src/pages/SavTickets.tsx:376` |
|
||||
| [SAV-21](#sav-21) | basse | coherence-design | S | Badges ambigus : 'Nouveau' commentaire contre statut 'Nouveau', couleurs priorité/statut qui se chevauchent | `client/src/pages/SavTickets.tsx:856` |
|
||||
| [SAV-24](#sav-24) | basse | ux-simplicite | S | Changement de statut/priorité enregistré immédiatement, sans confirmation, avec un message inexact | `client/src/pages/SavTickets.tsx:1123` |
|
||||
| [SAV-25](#sav-25) | basse | ux-simplicite | S | Les tickets fermés sont mélangés aux tickets à traiter par défaut | `client/src/pages/SavTickets.tsx:59` |
|
||||
| [SAV-26](#sav-26) | basse | bug | S | Numéros de ticket dupliqués possibles (count(*) + 1) et génération en double | `server/storage.ts:2750` |
|
||||
| [SAV-27](#sav-27) | basse | perf-serveur | S | Index SAV absents du schéma Drizzle, pas d'index composite magasin+date ni fournisseur | `shared/schema.ts:869` |
|
||||
| [SAV-28](#sav-28) | basse | ux-simplicite | M | Formulaire de création : fournisseur sans recherche et validation non localisée | `client/src/pages/SavTickets.tsx:536` |
|
||||
| [SAV-29](#sav-29) | basse | perf-bundle | S | Pages SAV desktop et mobile importées statiquement dans le bundle initial | `client/src/components/RouterProduction.tsx:25` |
|
||||
|
||||
### MSAV-01
|
||||
|
||||
**La page mobile appelle des endpoints inexistants (/api/sav-tickets) : SAV mobile inutilisable** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:88`
|
||||
- **Constat :** L88 `fetch(`/api/sav-tickets?storeId=${selectedStoreId}`)`, L108 `apiRequest(`/api/sav-tickets/${id}/status`, 'PATCH', ...)`, L116 `apiRequest('/api/sav-tickets', 'POST', data)`. Aucune route de ce nom dans le serveur (grep 'sav-tickets' dans server/ : 0 résultat). Seuls `/api/sav/tickets` et `/api/sav/tickets/:id` existent (routes.ts:5242-5443). En production, index.production.ts:157-160 fait `next()` pour /api/, d'où un 404.
|
||||
- **Impact :** Sur téléphone, la liste affiche toujours 'Aucun ticket SAV', la création renvoie un toast d'erreur contenant le HTML du 404, et le changement de statut échoue sans message. Les employés en magasin ne peuvent pas utiliser le SAV.
|
||||
- **Recommandation :** Utiliser `/api/sav/tickets` (GET avec `groupId` pour l'admin, comme sur desktop, en partageant la même queryKey), `PATCH /api/sav/tickets/:id` avec `{ status }`, et `POST /api/sav/tickets`. Idéalement, extraire un hook commun `useSavTickets()` partagé entre desktop et mobile.
|
||||
|
||||
### MSAV-02
|
||||
|
||||
**Valeurs de statut en anglais, hors de l'enum serveur** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:230`
|
||||
- **Constat :** L230-239 : `status: 'in_progress'`, `'waiting_parts'`, `'resolved'`, `'closed'`. Le schéma attend `z.enum(["nouveau", "en_cours", "attente_pieces", "attente_echange", "resolu", "ferme"])` (schema.ts:919), et init.sql:356 impose un CHECK sur ces valeurs. Le menu ne propose pas non plus 'Nouveau' ni 'Attente échange'.
|
||||
- **Impact :** Même après correction de l'URL (MSAV-01), chaque changement de statut sera rejeté (400 Validation error), et un ticket en 'Attente échange' ne pourra pas être géré sur mobile.
|
||||
- **Recommandation :** Utiliser les valeurs de l'enum ('en_cours', 'attente_pieces', 'attente_echange', 'resolu', 'ferme') via un statusConfig partagé avec le desktop (à extraire dans un fichier commun).
|
||||
|
||||
### SAV-01
|
||||
|
||||
**La liste ne se rafraîchit pas après création/modification/suppression (queryKey incompatible avec l'invalidation)** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:98`
|
||||
- **Constat :** L95-100 : `const ticketsUrl = `/api/sav/tickets${queryParams...}`` puis `queryKey: [ticketsUrl, selectedStoreId], staleTime: 1000 * 60 * 5`. Les mutations font `queryClient.invalidateQueries({ queryKey: ['/api/sav/tickets'] })` (L170, 209, 240, 268). TanStack compare les éléments du tableau un à un : '/api/sav/tickets' !== '/api/sav/tickets?groupId=3'. Dès qu'un admin a un magasin sélectionné (L91-93 ajoute groupId) ou qu'un filtre est actif, l'invalidation ne correspond à aucune requête.
|
||||
- **Impact :** Un ticket créé n'apparaît pas, un ticket supprimé reste affiché (un second clic sur Supprimer renvoie 'Ticket not found') et un statut modifié reste ancien sur la carte, pendant 5 minutes. L'utilisateur croit que l'action a échoué et la refait (doublons).
|
||||
- **Recommandation :** Partie auto-applicable : clé `['/api/sav/tickets', { status, priority, supplierId, groupId }]` avec une queryFn qui reconstruit l'URL via apiRequest (le message '401: ...' garde la redirection du retry global). Alternative minimale : `invalidateQueries({ predicate: q => String(q.queryKey[0]).startsWith('/api/sav/tickets') })`. Après un commentaire, n'invalider que le détail. Le passage à setQueryData est une optimisation à faire à part, non automatique.
|
||||
|
||||
### SAV-02
|
||||
|
||||
**Les commentaires et l'historique ne sont jamais enregistrés, mais l'UI affiche un succès** — bug, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2808`
|
||||
- **Constat :** storage.ts:2808-2816 `async addSavTicketHistory(...) { // const [history] = await db.insert(savTicketHistory)... return null; }` et 2790-2805 `getSavTicketHistory(...) { // ... return []; }`. La route routes.ts:5508 renvoie 201. Côté client, SavTickets.tsx:214-218 affiche le toast 'Commentaire ajouté', mais la colonne 'Historique et commentaires' (L1154-1177) montre toujours 'Aucun commentaire pour le moment'. Les changements de statut (routes.ts:5424-5430) ne sont pas historisés non plus, et `resolvedAt`/`closedAt` (schema.ts:887-888) ne sont jamais renseignés par le PATCH (routes.ts:5420-5421).
|
||||
- **Impact :** Perte silencieuse des informations saisies par les managers (suivi fournisseur, échanges client). La fonction 'Nouveau' (commentaire < 24 h, L853) ne s'allume jamais. Aucune traçabilité de qui a changé un statut, ni de date de résolution.
|
||||
- **Recommandation :** Recréer la table sav_ticket_history (déjà définie dans migrations/sav_production_fix.sql:41), déclarer `savTicketHistory` dans shared/schema.ts et décommenter les implémentations. Dans updateSavTicket, positionner resolvedAt/closedAt quand le statut passe à 'resolu'/'ferme'. En attendant, masquer le champ commentaire plutôt que d'afficher un faux succès.
|
||||
|
||||
### SAV-03
|
||||
|
||||
**Bouton 'Modifier' : formulaire complet dont la sauvegarde n'est pas implémentée** — ux-simplicite, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:1338`
|
||||
- **Constat :** L1337-1344 : `onClick={() => { // TODO: Implement update ticket functionality
|
||||
toast({ title: "Fonction en développement", description: "La modification de tickets sera bientôt disponible." }); }}`. Le bouton crayon est pourtant affiché sur chaque carte pour admin/directeur/manager (L932-941). Le PATCH serveur accepte déjà une mise à jour partielle (routes.ts:5420 `insertSavTicketSchema.partial().parse(req.body)`).
|
||||
- **Impact :** L'utilisateur remplit 9 champs puis découvre que rien n'est enregistré : frustration et perte de temps sur une action visible en permanence.
|
||||
- **Recommandation :** Si on branche la sauvegarde : envoyer seulement les champs éditables (supplierId en entier, produit, problemType, problemDescription, priority, clientName, clientPhone), jamais groupId ni createdBy. Côté serveur, retirer groupId/createdBy/ticketNumber du schéma PATCH (ou vérifier l'accès au nouveau groupId). Sinon, masquer le bouton crayon : c'est une décision produit.
|
||||
|
||||
### SAV-04
|
||||
|
||||
**Le hash du mot de passe du créateur est envoyé au navigateur pour chaque ticket** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2669`
|
||||
- **Constat :** storage.ts:2665-2669 `.select({ ticket: savTickets, supplier: suppliers, group: groups, creator: users })` et de même 2722-2726 pour getSavTicket. La table users contient `password: varchar("password")` (schema.ts:40). Le middleware de sortie (routes.ts:165-171, sanitize.ts:36) ne retire que smtpPassword. Par contraste, /api/user (localAuth.ts:217-226) choisit ses champs explicitement.
|
||||
- **Impact :** Tout employé qui ouvre la page SAV reçoit les hash bcrypt des managers/admins créateurs de tickets (craquage hors ligne possible). Cela alourdit aussi chaque ticket de colonnes inutiles.
|
||||
- **Recommandation :** Comme proposé. Ajuster aussi le type `SavTicketWithRelations.creator` (schema.ts:939) en `Pick<User,'id'|'username'|'firstName'|'lastName'>`, sinon getSavTicket ne type-checke plus. MemStorage (4940, 4962) peut faire la même projection.
|
||||
|
||||
### SAV-05
|
||||
|
||||
**Chaque ticket embarque la ligne magasin complète, logo base64 et config SMTP/NocoDB compris** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2668`
|
||||
- **Constat :** storage.ts:2667-2668 `supplier: suppliers, group: groups` (lignes complètes). groups contient `logo: text("logo"), // Logo en data URI (data:image/png;base64,...)` (schema.ts:67), plus smtpHost/smtpUser/webhookUrl/nocodb*. Le client n'utilise que `ticket.supplier?.name` (L911, L1067) et `ticket.group?.name` (L1071).
|
||||
- **Impact :** Avec un logo de 50-200 Ko, 100 tickets donnent 5 à 20 Mo de JSON à télécharger, parser et traverser récursivement (stripSmtpPassword) à chaque chargement et chaque filtre. Le chargement est lent sur le Wi-Fi ou la 4G du magasin, et des données sensibles (hôte/utilisateur SMTP) fuient.
|
||||
- **Recommandation :** Partie auto-applicable : projection `supplier: { id, name }`, `group: { id, name, color }` dans getSavTickets/getSavTicket, et types ajustés (Pick<Supplier,...>, Pick<Group,...>). Ajouter la compression gzip est une autre décision : nouvelle dépendance, effet global sur toute l'API, et à vérifier avec un éventuel proxy nginx. À traiter à part, pas automatiquement.
|
||||
|
||||
### SAV-06
|
||||
|
||||
**Un utilisateur non admin sans magasin assigné voit les tickets de tous les magasins** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:5272`
|
||||
- **Constat :** routes.ts:5266 `groupIds = userGroups ? userGroups.map(...) : [];` puis 5272 `groupIds: groupIds.length > 0 ? groupIds : undefined`. Dans storage.ts:2678, `if (filters?.groupIds?.length)` : aucun filtre magasin n'est appliqué. Même logique pour les stats (routes.ts:5548, 5552).
|
||||
- **Impact :** Un employé ou un manager fraîchement créé, ou dont l'affectation a été retirée, voit les tickets SAV (noms et téléphones clients) de toute l'enseigne.
|
||||
- **Recommandation :** Pour un non-admin dont groupIds est vide, renvoyer immédiatement `res.json([])` (et des stats à zéro) au lieu de passer `undefined`.
|
||||
|
||||
### MSAV-03
|
||||
|
||||
**Les erreurs de validation du formulaire mobile ne s'affichent jamais** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:283`
|
||||
- **Constat :** FormMessage est importé (L51), mais aucun FormItem ne le rend, par exemple L283-286 `<FormItem><FormLabel>Gencode (EAN13)</FormLabel><FormControl><Input .../></FormControl></FormItem>`. Le schéma zod définit pourtant 'Gencode requis', 'Désignation requise', 'Fournisseur requis', 'Description requise' (L66-71). supplierId vaut 0 par défaut (L135).
|
||||
- **Impact :** En appuyant sur 'Créer le ticket' avec un champ manquant, rien ne se passe à part un libellé qui devient rouge en haut d'une feuille de 90vh. L'utilisateur pense que le bouton ne marche pas.
|
||||
- **Recommandation :** Ajouter `<FormMessage />` dans chaque FormItem et faire défiler jusqu'au premier champ en erreur (`shouldFocusError` est actif par défaut avec les champs enregistrés).
|
||||
|
||||
### MSAV-04
|
||||
|
||||
**Bouton + et menu de statut visibles pour les employés, qui n'ont pas le droit** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:397`
|
||||
- **Constat :** L397-402 : le FAB `<Button className="fixed bottom-20 right-4 ..." onClick={() => setIsCreateOpen(true)}>` et le menu ⋮ (L223-243) sont toujours rendus. Le desktop limite pourtant `canCreate`/`canModify` à admin/directeur/manager (SavTickets.tsx:287-288), et le serveur renvoie 403 aux employés (routes.ts:5328, 5399).
|
||||
- **Impact :** Un employé remplit tout le formulaire puis reçoit une erreur 'Insufficient permissions' en anglais.
|
||||
- **Recommandation :** Reprendre les mêmes règles de rôle que le desktop (hook partagé `useSavPermissions`) et masquer le FAB et le menu ⋮ si l'utilisateur n'a pas le droit.
|
||||
|
||||
### MSAV-05
|
||||
|
||||
**Mobile : ni détail, ni commentaires, ni confirmation de fermeture, ni gestion d'erreur du statut** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:106`
|
||||
- **Constat :** Les cartes (L209-259) ne sont pas cliquables : seuls le menu ⋮ et 'Appeler' sont présents, sans accès au fournisseur, au gencode ni à l'historique. `statusMutation` (L106-113) n'a pas de onError. 'Fermer' (L239) s'applique en un tap, sans confirmation.
|
||||
- **Impact :** Sur le terrain, l'employé ne voit pas les détails nécessaires pour traiter le SAV (fournisseur, code-barres). Un tap accidentel ferme un ticket, et un échec passe inaperçu.
|
||||
- **Recommandation :** Ouvrir une Sheet de détail au tap sur la carte (infos, statut, commentaires). Ajouter onError avec un toast français et une confirmation pour Résolu/Fermé.
|
||||
|
||||
### SAV-07
|
||||
|
||||
**Création par un admin : ticket rattaché au premier magasin alphabétique et non au magasin sélectionné** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:337`
|
||||
- **Constat :** L328 `let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null;` puis L337-339 `if (!selectedGroupId && availableGroups.length > 0) { selectedGroupId = availableGroups[0].id; }` et L345 `groupId: selectedGroupId || 1`. Pour un admin, availableGroups = tous les groupes triés par nom (storage.ts:433). Le Select 'Magasin' (L553) n'a pas de valeur par défaut et `selectedStoreId` n'est pas utilisé.
|
||||
- **Impact :** Un admin qui travaille sur le magasin B et ne touche pas le Select crée le ticket dans le magasin A. Le ticket disparaît ensuite de sa vue filtrée sur B.
|
||||
- **Recommandation :** Initialiser `formData.groupId` avec `selectedStoreId` (ou le seul magasin de l'utilisateur) à l'ouverture de la modale. Supprimer le repli `|| 1`.
|
||||
|
||||
### SAV-08
|
||||
|
||||
**Le formulaire 'Nouveau Ticket' est pré-rempli avec le dernier ticket ouvert en modification** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:400`
|
||||
- **Constat :** handleEditTicket (L397-412) fait `setFormData({ supplierId: ticket.supplierId.toString(), ... })`. La modale d'édition se ferme sans réinitialisation (L1334 `setShowEditModal(false)`), et la modale de création lit le même état `formData` (L506, 516, 527...). Seul le succès de création le réinitialise (L173).
|
||||
- **Impact :** Après avoir ouvert un ticket en modification, 'Nouveau Ticket' affiche les données de l'ancien ticket. Risque de créer un doublon ou un ticket mélangé.
|
||||
- **Recommandation :** Préférer des états séparés (editFormData / createFormData), ou réinitialiser formData à la fermeture de la modale d'édition. Ne pas le réinitialiser à l'ouverture de la création : on perdrait le brouillon de création que l'utilisateur garde aujourd'hui quand il ferme et rouvre.
|
||||
|
||||
### SAV-09
|
||||
|
||||
**DELETE : aucun contrôle d'appartenance au magasin** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:5446`
|
||||
- **Constat :** routes.ts:5446 `const user = await storage.getUser(userId);` puis 5452 `if (!['admin', 'directeur'].includes(user.role))` puis directement 5463 `await storage.deleteSavTicket(ticketId);`. Contrairement au GET/PATCH (5304-5310, 5411-5417), userGroups n'est pas vérifié.
|
||||
- **Impact :** Un directeur peut supprimer définitivement un ticket d'un autre magasin en devinant un id.
|
||||
- **Recommandation :** Utiliser req.user (qui contient déjà userGroups) et vérifier `userGroupIds.includes(existingTicket.groupId)` pour les non-admin, comme dans le PATCH.
|
||||
|
||||
### SAV-10
|
||||
|
||||
**Chaque route SAV recharge l'utilisateur et ses magasins déjà fournis par la session** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:5245`
|
||||
- **Constat :** localAuth.ts:141-143 `passport.deserializeUser(async (id) => { const user = await storage.getUserWithGroups(id); ...` (idem localAuth.production.ts:193-195). Pourtant routes.ts:5245, 5291, 5322, 5393, 5474, 5527 refont `await storage.getUserWithGroups(userId)` (2 requêtes séquentielles : storage.ts:372 getUser puis 376 user_groups). Les PATCH, DELETE et history (5404, 5457, 5485) chargent aussi le ticket complet avec 3 jointures et l'historique, uniquement pour lire groupId/status.
|
||||
- **Impact :** Au montage de la page (tickets + stats), 4 requêtes SQL inutiles s'ajoutent. Chaque mutation coûte 3 à 5 allers-retours BD au lieu de 1 à 2, soit +5 à 20 ms par appel selon la latence Postgres.
|
||||
- **Recommandation :** Utiliser directement `req.user` (déjà de type UserWithGroups) dans les routes SAV. Pour les contrôles d'accès, ajouter une méthode légère `getSavTicketAccessInfo(id)` qui fait `select({ groupId, status }).from(savTickets).where(eq(id))`.
|
||||
|
||||
### SAV-11
|
||||
|
||||
**Statistiques SAV : 3 requêtes séquentielles au lieu d'une seule** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2832`
|
||||
- **Constat :** storage.ts:2832-2855 : `await db.select({count, status})...groupBy(status)`, puis `await db.select({count, priority})...groupBy(priority)`, puis `await db.select({ count: sql`count(*)` })...`. Les trois sont awaités l'un après l'autre. La variable `baseQuery` (L2825-2829) est construite puis jamais utilisée.
|
||||
- **Impact :** Trois allers-retours BD par affichage de la page (plus les 2 de getUserWithGroups), ce qui retarde l'apparition des compteurs.
|
||||
- **Recommandation :** Une seule requête avec `count(*) filter (where ...)` qui garde exactement la sémantique actuelle : total ; new = 'nouveau' ; in_progress = en_cours/attente_pieces/attente_echange ; resolved = resolu/ferme ; critical = priority='critique' sans condition de statut. Convertir les résultats avec Number() (node-postgres renvoie count en chaîne). Supprimer baseQuery. Changer la définition de 'Critiques' seulement dans le cadre de SAV-22.
|
||||
|
||||
### SAV-12
|
||||
|
||||
**Schéma N+1 : un appel getSavTicketHistory par ticket de la liste** — perf-serveur, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/storage.ts:2704`
|
||||
- **Constat :** storage.ts:2704-2715 `await Promise.all(results.map(async (result) => { const history = await this.getSavTicketHistory(result.ticket.id); ... }))`. La fonction est aujourd'hui un no-op (`return []`), mais réactiver l'historique (SAV-02) produira N requêtes SQL pour N tickets. La liste n'utilise l'historique que pour hasRecentComments (SavTickets.tsx:383-394).
|
||||
- **Impact :** Une fois l'historique réactivé, 200 tickets donnent 201 requêtes par affichage et par changement de filtre. Aujourd'hui, ce sont N promesses inutiles et un champ history vide sérialisé.
|
||||
- **Recommandation :** Pour l'instant, se contenter de supprimer l'appel no-op dans la liste et renvoyer `history: []` (même format pour le client, hasRecentComments continue de marcher). Concevoir lastCommentAt (sous-requête ou inArray groupé) en même temps que la recréation de la table (SAV-02), pas avant.
|
||||
|
||||
### SAV-13
|
||||
|
||||
**Liste sans pagination ni limite, rendue entièrement en cartes** — perf-api, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2701`
|
||||
- **Constat :** storage.ts:2701 `const results = await query.orderBy(desc(savTickets.createdAt));` sans limit/offset. Côté client, SavTickets.tsx:840 `{filteredTickets.map((ticket) => { ... <Card> ...` rend toutes les cartes dans une grille 3 colonnes, tickets fermés compris.
|
||||
- **Impact :** Le volume grossit indéfiniment (les tickets fermés ne sont jamais archivés) : temps de réponse, poids JSON et rendu DOM augmentent chaque mois, et la page devient longue à défiler.
|
||||
- **Recommandation :** Ajouter `limit`/`offset` (ou un curseur sur created_at) côté serveur avec un total. Côté client, afficher 30 tickets puis 'Charger plus' (useInfiniteQuery). Par défaut, exclure les tickets 'ferme' (voir SAV-24).
|
||||
|
||||
### SAV-14
|
||||
|
||||
**Chaque changement de filtre vide la liste et affiche un spinner (aucun placeholderData)** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:97`
|
||||
- **Constat :** L95-101 : la clé change à chaque filtre (`queryKey: [ticketsUrl, selectedStoreId]`) sans `placeholderData`. L827 `{isLoading ? (<div className="animate-spin ..."/> <p>Chargement des tickets...</p>)`. Les filtres statut/priorité/fournisseur sont envoyés au serveur (L86-88), alors que la recherche texte est faite côté client (L445-455) sur la même liste.
|
||||
- **Impact :** À chaque clic sur un filtre, la grille disparaît, un spinner s'affiche, puis un aller-retour serveur complet a lieu (avec le payload lourd de SAV-05). L'interface paraît lente et saute visuellement.
|
||||
- **Recommandation :** Partie auto-applicable : seulement `placeholderData: keepPreviousData`. Le passage à un filtrage entièrement côté client (une seule requête par magasin) dépend de SAV-13 (pagination) et n'est pas mécanique.
|
||||
|
||||
### SAV-15
|
||||
|
||||
**console.log de debug exécutés à chaque rendu (chaque frappe clavier)** — dette-code, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:294`
|
||||
- **Constat :** `const availableGroups = getUserGroups();` (L314) est appelé dans le corps du composant. getUserGroups loggue `console.log('🎫 [CLIENT] Admin user - can access all groups:', ...)` (L294) ou un objet complet avec username et groupes (L304-309). D'autres logs dans handleCreateTicket (L330-335, L340, L358-365) affichent les données du ticket et du client. Le filtrage `groupsData.filter(...)` est aussi recalculé à chaque rendu, alors que /api/groups renvoie déjà uniquement les magasins de l'utilisateur non admin (routes.ts:983).
|
||||
- **Impact :** Chaque caractère tapé dans la recherche ou le formulaire produit un log : console saturée, ralentissement perceptible avec les DevTools ouverts, données client (nom, téléphone) exposées dans la console.
|
||||
- **Recommandation :** Supprimer tous les console.log de la page. Remplacer getUserGroups par `const availableGroups = groupsData` (le serveur filtre déjà), ou utiliser `stores` de StoreContext, mémorisé avec useMemo.
|
||||
|
||||
### SAV-18
|
||||
|
||||
**Codes techniques, anglais et emojis affichés à l'utilisateur** — lisibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:1034`
|
||||
- **Constat :** L1034 `<p>{displayedTicket.problemType}</p>` affiche 'pieces_manquantes'/'defectueux'. L1160 `{entry.action === 'comment' ? 'Commentaire' : entry.action}` affiche 'status_change'. routes.ts:5428 `description: `Statut changé de "${existingTicket.status}" vers "${req.body.status}"`` produit 'de "en_cours" vers "resolu"'. L871 `{ticket.clientName || 'N/A'}`. L875 `📞 {ticket.clientPhone}` et L889 `📦 {ticket.productGencode}` mélangent emojis et icônes lucide.
|
||||
- **Impact :** Un employé non technicien lit des codes informatiques et de l'anglais ('N/A') : la compréhension baisse et l'interface paraît peu finie.
|
||||
- **Recommandation :** Créer un `problemTypeConfig` (Défectueux, Pièces manquantes, Non conforme, Autre) réutilisé partout. Traduire les actions d'historique ('Changement de statut'). Côté serveur, utiliser les libellés français dans la description. Remplacer 'N/A' par 'Client non renseigné', et les emojis par les icônes Phone/Barcode de lucide.
|
||||
|
||||
### SAV-19
|
||||
|
||||
**Modale de suivi trop chargée : 'Actions Rapides' coupé en bas sur écran de portable** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:966`
|
||||
- **Constat :** L966 `<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden">`. La colonne gauche (L975 `<div className="space-y-4">`, sans overflow) empile 5 Cards (Produit, Client, Problème, Informations Ticket, Actions Rapides), soit environ 900 px. Seule la colonne droite défile (L1150 `overflow-y-auto max-h-[70vh]`). En dessous de lg, `grid-cols-1` place les deux colonnes l'une sous l'autre dans un conteneur overflow-hidden.
|
||||
- **Impact :** Sur un écran 1366x768 ou une tablette, la carte 'Actions Rapides' (changer le statut, l'action principale) est invisible et inatteignable. L'utilisateur ne trouve pas comment clôturer un ticket.
|
||||
- **Recommandation :** Mettre `overflow-y-auto` sur DialogContent. Placer Statut et Priorité en haut de la modale (juste sous le titre), et fusionner Produit, Client et Problème en une seule carte compacte de type 'définition'.
|
||||
|
||||
### SAV-20
|
||||
|
||||
**Cartes non cliquables malgré cursor-pointer ; actions en icônes minuscules sans libellé** — accessibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:846`
|
||||
- **Constat :** L846 `<Card ... className={`hover:shadow-md transition-shadow cursor-pointer ...`}>` sans onClick. Les actions (L923-950) sont des boutons `className="text-xs h-7"` contenant seulement `<ClipboardList className="h-3 w-3" />`, `<Edit .../>`, `<Trash2 .../>`. Seul le premier a un `title`, et aucun n'a d'aria-label.
|
||||
- **Impact :** L'utilisateur clique sur la carte et rien ne se passe. Il doit deviner que l'icône presse-papier signifie 'Suivi'. Les cibles de 28 px sur des icônes de 12 px sont difficiles à viser, et les lecteurs d'écran annoncent 'bouton' sans nom.
|
||||
- **Recommandation :** Partie auto-applicable : ajouter aria-label et title ('Suivi du ticket', 'Modifier', 'Supprimer') sur les 3 boutons. Mettre `onClick={() => handleViewTicket(ticket)}` sur la Card, avec `e.stopPropagation()` dans les onClick des boutons Modifier/Supprimer/Suivi, ce qui rend réel le comportement déjà suggéré par cursor-pointer. Le menu ⋮ et l'agrandissement des boutons restent à décider côté design.
|
||||
|
||||
### SAV-22
|
||||
|
||||
**Compteurs trompeurs et non cliquables (Critiques inclut les tickets clos, Résolus inclut Fermés)** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2882`
|
||||
- **Constat :** storage.ts:2879-2884 : `if (result.priority === 'critique') stats.criticalTickets = count;` sans condition de statut. L2873 : `['resolu', 'ferme']` est compté dans resolvedTickets. Côté UI (SavTickets.tsx:703-750), 5 Cards statiques sans onClick, alors que le filtre propose 6 statuts différents.
|
||||
- **Impact :** Le compteur rouge 'Critiques' reste élevé alors que tous les tickets critiques sont résolus, ce qui crée une fausse alerte. L'utilisateur ne peut pas cliquer sur 'En cours' pour voir ces tickets.
|
||||
- **Recommandation :** Ne compter que les tickets critiques ouverts. Rendre chaque carte compteur cliquable pour appliquer le filtre correspondant. Réduire à 4 compteurs utiles : À traiter, En attente, Urgents ouverts, Clôturés ce mois.
|
||||
|
||||
### SAV-23
|
||||
|
||||
**Pas d'état d'erreur, état vide sans action, et compteurs qui apparaissent en décalant la page** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:832`
|
||||
- **Constat :** L97 `const { data: ticketsData = [], isLoading } = useQuery(...)` : isError n'est jamais lu. Si l'API échoue, L832-836 affiche 'Aucun ticket trouvé'. Cet état vide ne distingue pas 'aucun ticket' de 'aucun résultat pour ces filtres' et ne propose ni 'Créer un ticket' ni 'Réinitialiser les filtres'. L703 `{statsData && (<div className="grid ...">` insère les 5 cartes après chargement, ce qui pousse filtres et liste vers le bas.
|
||||
- **Impact :** Pendant une panne, l'utilisateur croit qu'il n'y a pas de SAV. Après un filtrage sans résultat, il ne sait pas quoi faire. Le saut de mise en page provoque des clics ratés.
|
||||
- **Recommandation :** Afficher un encart d'erreur avec un bouton 'Réessayer' quand isError est vrai. Proposer un état vide contextuel avec une action ('Effacer les filtres' ou 'Créer un ticket'). Réserver la place des compteurs avec des Skeleton de même hauteur pendant le chargement.
|
||||
|
||||
### MSAV-06
|
||||
|
||||
**Libellés et couleurs de statut différents du desktop ; statut 'attente_echange' affiché brut** — coherence-design, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:165`
|
||||
- **Constat :** L169 `case "attente_pieces": ... >Pièces</Badge>` contre 'Attente pièces' sur desktop (SavTickets.tsx:39). Il n'y a pas de cas 'attente_echange', d'où `default: return <Badge variant="outline">{status}</Badge>` (L172) qui affiche le code. Le menu L234 écrit 'Attente Pièces' (majuscule). Le header et le FAB sont violets (L181 `text-purple-600`, L398 `bg-purple-600`), alors que le desktop utilise le bleu/primary (SavTickets.tsx:464).
|
||||
- **Impact :** Un même ticket a un libellé différent selon l'appareil, et un code technique apparaît : la confiance et la compréhension s'en ressentent.
|
||||
- **Recommandation :** Partie auto-applicable : extraire statusConfig/priorityConfig/problemTypeConfig dans client/src/lib/savConfig.ts et les utiliser dans les deux pages (libellés français identiques, cas attente_echange inclus). Ne pas toucher aux couleurs d'accent mobile sans décision de design globale.
|
||||
|
||||
### MSAV-07
|
||||
|
||||
**Jargon 'Gencode (EAN13)' et placeholder 'Scanner...' sans scanner** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:284`
|
||||
- **Constat :** L284-285 `<FormLabel>Gencode (EAN13)</FormLabel><FormControl><Input placeholder="Scanner..." .../>`. L'icône Camera est importée (L19) mais aucune fonction de scan n'existe. Le desktop dit 'Code-barres produit' (SavTickets.tsx:503).
|
||||
- **Impact :** Le terme technique et la promesse d'un scan inexistant déroutent l'utilisateur, qui cherche un bouton caméra.
|
||||
- **Recommandation :** Libellé 'Code-barres du produit', placeholder 'Ex. 3017620422003'. Ajouter un vrai bouton de scan plus tard, ou retirer la mention 'Scanner'.
|
||||
|
||||
### MSAV-08
|
||||
|
||||
**Imports inutilisés et requêtes chargées au montage pour un formulaire fermé** — dette-code, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:100`
|
||||
- **Constat :** Imports inutilisés : `Filter, Camera, Image as ImageIcon, CheckCircle, XCircle` (L18-23) et `FormMessage` (L51). `useQuery(['/api/groups'])` (L100-103) ne sert qu'au repli `groups[0].id` de onSubmit (L145), et `/api/suppliers` (L95-98) ne sert qu'au formulaire. Les deux sont chargés au montage sans staleTime spécifique.
|
||||
- **Impact :** Deux requêtes réseau inutiles à chaque ouverture de la page sur mobile (connexion souvent lente), et du code mort.
|
||||
- **Recommandation :** Supprimer les imports morts. Remplacer useQuery('/api/groups') par `stores` de useStore(), qui contient les mêmes données (MobileApp.tsx). Mettre staleTime 10 min sur les fournisseurs. Ne pas conditionner les fournisseurs à l'ouverture de la Sheet.
|
||||
|
||||
### MSAV-09
|
||||
|
||||
**Erreur ou absence de magasin sélectionné affichée comme 'Aucun ticket SAV'** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/SavPage.tsx:202`
|
||||
- **Constat :** L84-93 : `isError` n'est jamais lu. Avec `enabled: !!selectedStoreId && !!user`, un admin en 'Tous les magasins' voit isLoading=false et la liste vide. L202-206 affiche alors 'Aucun ticket SAV'. Par ailleurs, L216 `format(new Date(ticket.createdAt), "dd/MM")` omet l'année et lève une exception si la date est invalide.
|
||||
- **Impact :** Panne réseau, magasin non choisi et absence réelle de ticket produisent le même message : l'utilisateur ne sait pas quoi faire.
|
||||
- **Recommandation :** Distinguer trois états : erreur ('Impossible de charger, Réessayer'), aucun magasin choisi ('Choisissez un magasin') et liste vide (avec bouton 'Créer un ticket'). Utiliser safeFormat avec 'dd/MM/yy'.
|
||||
|
||||
### SAV-16
|
||||
|
||||
**Logs console sur chaque requête GET SAV (chemin chaud)** — perf-serveur, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:5258`
|
||||
- **Constat :** routes.ts:5258 `console.log(`🎫 [SAV] Admin filtering by selected store: ${selectedGroupId}`)`, 5261, 5267 `console.log(`🎫 [SAV] User ${user.username} (${user.role}) can see stores:`, groupIds)`. De même pour les stats (5540, 5543, 5549) et la création (5337, 5341, 5354, 5374-5379).
|
||||
- **Impact :** Deux à trois lignes de log par affichage de page, en plus du log HTTP global. Cela pollue les logs de production et coûte en I/O synchrone sur stdout.
|
||||
- **Recommandation :** Supprimer ces console.log ou les passer derrière un logger de niveau debug désactivé en production.
|
||||
|
||||
### SAV-17
|
||||
|
||||
**Requête de détail déclarée deux fois, plus fetch manuels incohérents** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:376`
|
||||
- **Constat :** L142-146 `useQuery({ queryKey: [`/api/sav/tickets/${selectedTicket?.id}`], staleTime: 0 })` et L376-380, même clé, résultat `ticketDetails` jamais utilisé. Les stats utilisent un `fetch` manuel (L121-136) et la création un `fetch` brut (L154-167), alors que les autres mutations utilisent apiRequest. Le détail renvoie les mêmes données que la liste (history toujours []) et staleTime 0 force un appel à chaque ouverture.
|
||||
- **Impact :** Code plus difficile à maintenir, gestion d'erreur/401 incohérente entre les appels, et une requête réseau à chaque ouverture de ticket sans gain d'information.
|
||||
- **Recommandation :** Partie sûre : supprimer le second useQuery (L376-380), qui est placé avant le return conditionnel, donc sans risque pour l'ordre des hooks. Ne pas remplacer le fetch de création par apiRequest sans extraire le `message` du JSON d'erreur. placeholderData et staleTime 30 s pour le détail sont acceptables, puisque les mutations invalident explicitement la clé du détail.
|
||||
|
||||
### SAV-21
|
||||
|
||||
**Badges ambigus : 'Nouveau' commentaire contre statut 'Nouveau', couleurs priorité/statut qui se chevauchent** — coherence-design, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:856`
|
||||
- **Constat :** L853-857 badge orange `<MessageCircle/> Nouveau` pour un commentaire récent, alors que le statut 'nouveau' affiche aussi 'Nouveau' (L37, bleu). priorityConfig.normale = 'bg-blue-100 text-blue-800' (L47) a la même couleur que le statut 'Nouveau' (L37). priorityConfig.haute = 'bg-orange-100' (L48) a la même couleur que 'Attente pièces' (L39). La suppression utilise une modale maison (L1356-1394) au lieu du composant partagé `ConfirmationModal` utilisé par Avoirs, DlcPage et CustomerOrders.
|
||||
- **Impact :** Sur une carte, deux badges bleus ou deux badges orange ne permettent pas de distinguer statut et priorité. Le mot 'Nouveau' a deux sens, et les confirmations n'ont pas le même aspect d'une page à l'autre.
|
||||
- **Recommandation :** Pour harmoniser la suppression, utiliser AlertDialog comme Avoirs et DlcPage, pas ConfirmationModal. Renommer le badge en 'Nouveau message' seulement quand l'historique sera réactivé. Le code couleur de la priorité est un choix de design.
|
||||
|
||||
### SAV-24
|
||||
|
||||
**Changement de statut/priorité enregistré immédiatement, sans confirmation, avec un message inexact** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:1123`
|
||||
- **Constat :** L1099-1104 et L1123-1128 : `onValueChange={(value) => { setTempStatus(value); updateTicketMutation.mutate({ ticketId, status: value }); }}`, y compris pour 'ferme'. Le toast de succès dit toujours 'Le statut du ticket a été modifié avec succès.' (L249), même pour une priorité. En cas d'erreur (L252-258), tempStatus/tempPriority ne sont pas remis à zéro et le Select affiche une valeur non enregistrée. La mutation prévoit `resolutionDescription` (L231-235) mais aucun champ ne permet de la saisir.
|
||||
- **Impact :** Un mauvais clic ferme un ticket. Le message de succès ne correspond pas à l'action, et l'affichage peut mentir après une erreur.
|
||||
- **Recommandation :** Demander une confirmation (et un champ 'Comment le problème a été résolu ?') quand le statut passe à Résolu ou Fermé. Adapter le texte du toast à la priorité. Réinitialiser tempStatus/tempPriority dans onError.
|
||||
|
||||
### SAV-25
|
||||
|
||||
**Les tickets fermés sont mélangés aux tickets à traiter par défaut** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:59`
|
||||
- **Constat :** L59 `const [statusFilter, setStatusFilter] = useState("all");`. Les tickets fermés sont simplement grisés (L846 `${ticket.status === 'ferme' ? 'opacity-60' : ''}`) et triés uniquement par date de création (storage.ts:2701).
|
||||
- **Impact :** Au fil des mois, la page se remplit de tickets clos et les tickets urgents ouverts se perdent dans la grille.
|
||||
- **Recommandation :** Ajouter un filtre par défaut 'Ouverts' (nouveau, en cours, attente) avec des onglets simples 'À traiter / En attente / Clôturés'. Trier les tickets ouverts par priorité puis par ancienneté.
|
||||
|
||||
### SAV-26
|
||||
|
||||
**Numéros de ticket dupliqués possibles (count(*) + 1) et génération en double** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2750`
|
||||
- **Constat :** storage.ts:2750-2755 `count(*) ... where EXTRACT(year from created_at) = ${currentYear}` puis `SAV-${currentYear}-${count + 1}`. Après une suppression ou deux créations simultanées, le même numéro est réattribué. schema.ts:871 `ticketNumber: varchar(...).notNull()` n'a pas de contrainte unique. La route génère déjà un autre numéro (routes.ts:5350 `SAV-YYYYMMDD-xxxxxx`), qui est écrasé (code mort). EXTRACT(year ...) empêche aussi l'usage de l'index created_at.
|
||||
- **Impact :** Deux tickets peuvent porter le même numéro communiqué au client ou au fournisseur, d'où confusion et recherche ambiguë.
|
||||
- **Recommandation :** Corriger aussi la conversion : `Number(count[0]?.count ?? 0) + 1`. Vérifier en base les numéros existants avant de choisir une séquence ou une contrainte UNIQUE, car des doublons existants feraient échouer l'ajout de la contrainte.
|
||||
|
||||
### SAV-27
|
||||
|
||||
**Index SAV absents du schéma Drizzle, pas d'index composite magasin+date ni fournisseur** — perf-serveur, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `shared/schema.ts:869`
|
||||
- **Constat :** schema.ts:869-889 `pgTable("sav_tickets", {...})` sans 3e argument d'index, alors que la table session en déclare (schema.ts:28). Les index n'existent que dans init.sql:536-539 (status, priority, group_id, created_at séparés). La requête principale fait `WHERE group_id IN (...) ORDER BY created_at DESC`, plus éventuellement `supplier_id = ?` (storage.ts:2679, 2685, 2701).
|
||||
- **Impact :** Sur une base créée par `npm run db:push`, aucun index n'existe. Même avec init.sql, le tri par date après filtre magasin et le filtre fournisseur ne sont pas couverts.
|
||||
- **Recommandation :** Si on veut l'index composite, l'ajouter par `CREATE INDEX IF NOT EXISTS idx_sav_tickets_group_created ON sav_tickets(group_id, created_at DESC)` dans migrations.production.ts et init.sql, et éventuellement le refléter dans schema.ts. C'est une modification de base de données, à valider : pas d'application automatique.
|
||||
|
||||
### SAV-28
|
||||
|
||||
**Formulaire de création : fournisseur sans recherche et validation non localisée** — ux-simplicite, sévérité basse, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/SavTickets.tsx:536`
|
||||
- **Constat :** L536-546 `<Select value={formData.supplierId}>` avec `{suppliersData.map(...)}` liste tous les fournisseurs sans champ de recherche. Idem pour le filtre (L789-801). Validation L318-325 : un seul toast 'Veuillez remplir tous les champs obligatoires.' sans indiquer lequel ni le mettre en rouge. La modale (L480-695) répartit 10 champs dans 3 Cards avec titres et descriptions.
|
||||
- **Impact :** Choisir un fournisseur dans une longue liste est laborieux, et l'utilisateur ne sait pas quel champ manque.
|
||||
- **Recommandation :** Utiliser un Combobox (Command de shadcn) avec recherche pour le fournisseur. Valider avec react-hook-form + zod (comme la page mobile) et afficher l'erreur sous chaque champ. Alléger la modale en 2 blocs : 'Produit et problème' (obligatoires) puis 'Client (facultatif)' repliable.
|
||||
|
||||
### SAV-29
|
||||
|
||||
**Pages SAV desktop et mobile importées statiquement dans le bundle initial** — perf-bundle, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:25`
|
||||
- **Constat :** RouterProduction.tsx:25 `import SavTickets from "@/pages/SavTickets";` et :43 `import MobileSavPage from "@/pages/mobile/SavPage";`, sans React.lazy. Les deux versions sont donc chargées pour tous les utilisateurs, quel que soit l'appareil.
|
||||
- **Impact :** Environ 1 800 lignes de JSX (plus react-hook-form/zod côté mobile) chargées au démarrage même si l'utilisateur n'ouvre jamais le SAV, ce qui ralentit le premier affichage.
|
||||
- **Recommandation :** Appliquer lazy() à toutes les pages en une fois, avec un seul Suspense placé à l'intérieur de Layout et de MobileApp autour du Switch, plutôt qu'au cas par cas pour le SAV. Le gain pour le seul SAV est marginal.
|
||||
@@ -0,0 +1,492 @@
|
||||
# Commandes clients
|
||||
|
||||
_43 constats vérifiés — 7 haute, 23 moyenne, 13 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/customer-orders (desktop)`
|
||||
|
||||
**Rôle :** Suivre les commandes passées par les clients du magasin (produit non en stock commandé au fournisseur) : de la prise de commande jusqu'au retrait par le client, avec rappel du client quand le produit est disponible et impression d'une étiquette/fiche à code-barres.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Créer une commande client (nom, téléphone, produit via recherche gencode/référence, fournisseur, acompte)
|
||||
- Retrouver une commande (recherche texte, filtre fournisseur, filtre statut)
|
||||
- Faire avancer le statut (En attente -> En cours -> Disponible -> Retiré / Annulé)
|
||||
- Voir la liste des clients à appeler (bandeau orange + modale) et marquer un client comme contacté avec commentaire
|
||||
- Imprimer la fiche/étiquette d'une commande disponible
|
||||
- Consulter le détail, modifier, supprimer une commande
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/customer-orders[?storeId=X] (storeId seulement si admin) | au montage + à chaque changement de magasin + après chaque mutation (invalidate/refetch) | server/routes.ts:3486 -> storage.getUserWithGroups (storage.ts:371) + storage.getCustomerOrders (storage.ts:1868) | Renvoie TOUTES les commandes (historique Retiré/Annulé inclus) sans pagination, avec la ligne groups complète (logo base64, SMTP, NocoDB) et la ligne suppliers complète par commande. Filtrage/tri/pagination faits côté client. Même liste aussi téléchargée par Dashboard.tsx:81-93 juste pour compter par statut. |
|
||||
| GET /api/customer-orders/pending-calls?storeId=X | au montage + polling toutes les 30 s (refetchInterval: 30000), fetch() manuel dans queryFn | server/routes.ts:3638 -> storage.getUserWithGroups + storage.getPendingClientCalls (storage.ts:1931) | Sous-ensemble de la liste déjà chargée (status Disponible && !customerNotified). 403 systématique pour le rôle employee mais la requête est quand même lancée (avec 2 retries) toutes les 30 s. storeId ignoré côté serveur pour directeur/manager. |
|
||||
| GET /api/suppliers | au montage (filtre fournisseur) + dans CustomerOrderForm (même clé, dédupliqué) | server/routes.ts:1141 -> storage.getUser + storage.getSuppliers (storage.ts:467) | Liste complète des fournisseurs, même ceux sans commande client. |
|
||||
| GET /api/groups | au montage (CustomerOrders.tsx:69) + CustomerOrderForm.tsx:76 | server/routes.ts:970 -> storage.getUserWithGroups + storage.getGroups (storage.ts:432) | Résultat jamais utilisé dans CustomerOrders.tsx (variable groups morte) ; servi par le cache du Layout. |
|
||||
| POST /api/customer-orders | au clic Créer | server/routes.ts:3517 -> storage.createCustomerOrder (storage.ts:1913) | Aucune validation Zod (insertCustomerOrderSchema importé mais inutilisé), req.body inséré tel quel ; ~12 console.log de debug dont le body complet (données personnelles client). |
|
||||
| PUT /api/customer-orders/:id | au clic Modifier (formulaire), changement de statut, 'Annuler le contact' | server/routes.ts:3577 -> storage.getCustomerOrder (storage.ts:1892, 2 jointures + logo) + storage.updateCustomerOrder (storage.ts:1918) | Pas de contrôle de rôle (employee peut modifier malgré la matrice de permissions), req.body passé directement à .set() (groupId, createdBy modifiables). |
|
||||
| DELETE /api/customer-orders/:id | au clic Supprimer (après confirmation) | server/routes.ts:3607 -> storage.getCustomerOrder + storage.deleteCustomerOrder (storage.ts:1927) | Pas de contrôle de rôle serveur : un manager (sans droit delete) peut supprimer via l'API. |
|
||||
| PATCH /api/customer-orders/:id/mark-called | au clic Confirmer (modale contact) ou 'Marquer Appelé' (ClientCallsModal) | server/routes.ts:3674 -> storage.getCustomerOrder + storage.markClientCalled (storage.ts:1961) | 403 pour employee alors que le bouton téléphone est affiché à tous et que la mutation n'a pas de onError (échec silencieux). |
|
||||
| GET /api/ffnancy/articles?ean\|codein&limit=1 puis GET /api/ffnancy/mouvements/entrees?artNoId=... | au clic sur la loupe gencode/référence du formulaire (2 appels séquentiels) | server/routes.ts:6396 et 6411 (proxy fetch vers api.ffnancy.fr, sans timeout ni cache) | Waterfall de 2 allers-retours externes ; une erreur réseau est présentée comme 'Produit non référencé' et bloque la création. |
|
||||
|
||||
**Lisibilité / simplicité :** Page fonctionnelle mais dense et peu guidante : bandeau d'alerte collant au-dessus du titre, carte 'Recherche et Filtres' avec titre inutile, tableau de 9 colonnes sans défilement horizontal, 4 à 5 boutons-icônes sans libellé par ligne (dont l'ensemble varie selon le statut), statut modifiable en cliquant sur un badge (non découvrable, non accessible clavier) avec application immédiate y compris 'Annulé'. Pas d'état vide ni d'état d'erreur ('Chargement...' en texte brut). Libellés mélangés (option 'Status' en anglais, 'Fournisseurs' comme valeur 'tous', 'Gencode', 'Marquer Appelé' vs 'Marquer client comme contacté'). Formulaire de création/modification affiché à 90 % (transform scale) sans hauteur max : texte réduit et bouton Créer hors écran sur portable ; fournisseur n°1 présélectionné silencieusement ; le champ gencode (qui pré-remplit tout) arrive après la désignation. Modale Détails trompeuse (faux code-barres 'scannable', bloc 'Actions disponibles' sans bouton, notes et commentaire d'appel non affichés). Plusieurs bugs de données graves en modification (statut remis à 'En attente', commande déplacée de magasin, notes effacées). Console polluée de logs de debug.
|
||||
|
||||
### `/customer-orders (mobile)`
|
||||
|
||||
**Rôle :** Version téléphone des commandes clients : consulter la liste du magasin sélectionné, appeler le client, marquer contacté, changer rapidement le statut et créer une commande depuis le rayon.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Rechercher une commande (client, produit, référence)
|
||||
- Appeler le client (lien tel:) et le marquer comme contacté avec commentaire
|
||||
- Passer une commande en Disponible / Retiré / Annulé via le menu ⋮
|
||||
- Créer une commande via le bouton flottant (+) et la feuille 'Nouvelle Commande'
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/customer-orders?storeId=X | au montage, uniquement si selectedStoreId (enabled: !!selectedStoreId && !!user), fetch() manuel | server/routes.ts:3486 -> storage.getCustomerOrders (storage.ts:1868) | Même payload lourd que desktop (group complet avec logo par commande). Clé ['/api/customer-orders', selectedStoreId] identique à la clé desktop non-admin mais données différentes (filtrées par magasin ici, pas sur desktop). |
|
||||
| GET /api/suppliers | au montage, queryFn manuelle sans contrôle res.ok | server/routes.ts:1141 -> storage.getSuppliers (storage.ts:467) | Chargé même si la feuille de création n'est jamais ouverte (pourrait être enabled: isCreateOpen). |
|
||||
| GET /api/groups | au montage (déjà en cache via MobileApp) | server/routes.ts:970 -> storage.getGroups (storage.ts:432) | Utilisé seulement pour un fallback admin dans onSubmit. |
|
||||
| PUT /api/customer-orders/:id | au clic Marquer Disponible / Retiré / Annuler (menu ⋮) et 'Annuler le contact' | server/routes.ts:3577 -> storage.getCustomerOrder + storage.updateCustomerOrder (storage.ts:1918) | Aucune confirmation, aucun onError, accessible aux employés (pas de droit edit). |
|
||||
| PATCH /api/customer-orders/:id/mark-called | au clic Confirmer dans la feuille contact | server/routes.ts:3674 -> storage.markClientCalled (storage.ts:1961) | 403 silencieux pour les employés. |
|
||||
| POST /api/customer-orders | au clic 'Valider la commande' | server/routes.ts:3517 -> storage.createCustomerOrder (storage.ts:1913) | isPromotionalPrice et customerNotified perdus (retirés par zodResolver). |
|
||||
| GET /api/ffnancy/articles puis /api/ffnancy/mouvements/entrees | au clic loupe référence/gencode (2 appels séquentiels) | server/routes.ts:6396 et 6411 | Code dupliqué à l'identique de CustomerOrderForm.tsx. |
|
||||
|
||||
**Lisibilité / simplicité :** Cartes lisibles avec action 'Appeler' bien visible, mais : liste vide 'Aucune commande trouvée' dès que 'Tous les magasins' est sélectionné (requête désactivée), aucun filtre par statut ni séparation des commandes terminées (Retiré/Annulé mélangées aux actives), pas de pagination, menu ⋮ incomplet (pas 'Commande en cours') avec un 'Annuler' ambigu et sans confirmation, pas de bandeau 'clients à appeler'. Formulaire de création dupliqué du desktop, sans messages d'erreur de validation (FormMessage importé mais jamais rendu), cases 'Prix promotionnel' et 'Client déjà notifié' sans effet. Libellés abrégés ou fautifs ('Qty', 'Qté', 'Cmd Client', 'rév...'), date sans année, montant d'acompte non formaté, modale contact faite main (pas de focus/Échap).
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [CMDCLI-01](#cmdcli-01) | haute | perf-api | S | Chaque commande embarque la fiche magasin complète (logo base64, SMTP, NocoDB) et la fiche fournisseur complète | `server/storage.ts:1873` |
|
||||
| [CMDCLI-02](#cmdcli-02) | haute | perf-api | L | Liste sans pagination ni filtre serveur : tout l'historique est téléchargé puis filtré/trié/paginé en JS | `server/routes.ts:3508` |
|
||||
| [CMDCLI-03](#cmdcli-03) | haute | bug | S | Modifier une commande remet toujours son statut à « En attente de Commande » | `client/src/components/CustomerOrderForm.tsx:143` |
|
||||
| [CMDCLI-04](#cmdcli-04) | haute | bug | S | Modifier une commande peut la déplacer dans un autre magasin | `client/src/components/CustomerOrderForm.tsx:127` |
|
||||
| [CMDCLI-07](#cmdcli-07) | haute | bug | M | Permissions non appliquées côté serveur et corps de requête non validé (PUT/POST/DELETE) | `server/routes.ts:3599` |
|
||||
| [CMDCLI-08](#cmdcli-08) | haute | bug | S | Impression d'étiquette : injection HTML/JS (XSS) via les champs de la commande | `client/src/pages/CustomerOrders.tsx:388` |
|
||||
| [CMDCLI-20](#cmdcli-20) | haute | ux-simplicite | S | Formulaire de commande réduit à 90 % par transform et modale sans hauteur max : texte petit, bouton Créer hors écran | `client/src/pages/CustomerOrders.tsx:971` |
|
||||
| [CMDCLI-05](#cmdcli-05) | moyenne | bug | S | Modifier une commande efface ses notes et l'email client ; les notes ne sont affichées nulle part | `client/src/components/CustomerOrderForm.tsx:147` |
|
||||
| [CMDCLI-06](#cmdcli-06) | moyenne | bug | S | Mobile : les cases « Prix promotionnel / Pub » et « Client déjà notifié » n'ont aucun effet | `client/src/pages/mobile/CustomerOrdersPage.tsx:67` |
|
||||
| [CMDCLI-09](#cmdcli-09) | moyenne | perf-api | S | Polling pending-calls lancé pour les employés alors que le serveur répond 403 (avec 2 retries toutes les 30 s) | `client/src/pages/CustomerOrders.tsx:74` |
|
||||
| [CMDCLI-10](#cmdcli-10) | moyenne | bug | S | Mutations sans gestion d'erreur : échecs silencieux (statut, contact client) | `client/src/pages/CustomerOrders.tsx:171` |
|
||||
| [CMDCLI-11](#cmdcli-11) | moyenne | perf-serveur | S | getUserWithGroups rappelé dans chaque handler alors que deserializeUser l'a déjà chargé (4 requêtes SQL séquentielles par appel) | `server/routes.ts:3488` |
|
||||
| [CMDCLI-14](#cmdcli-14) | moyenne | perf-client | M | Chaque changement de statut/contact recharge toute la liste au lieu de mettre à jour la ligne | `client/src/pages/CustomerOrders.tsx:175` |
|
||||
| [CMDCLI-15](#cmdcli-15) | moyenne | bug | S | « Marquer Appelé » depuis la modale ne rafraîchit pas le tableau pour un admin avec magasin sélectionné | `client/src/components/modals/ClientCallsModal.tsx:42` |
|
||||
| [CMDCLI-16](#cmdcli-16) | moyenne | ux-simplicite | S | Le sélecteur de magasin n'a aucun effet pour directeur/manager (et clé de cache partagée avec le mobile) | `client/src/pages/CustomerOrders.tsx:95` |
|
||||
| [CMDCLI-17](#cmdcli-17) | moyenne | perf-client | S | Filtre + tri de toute la liste recalculés à chaque rendu (y compris à chaque frappe dans le commentaire d'appel) | `client/src/pages/CustomerOrders.tsx:657` |
|
||||
| [CMDCLI-18](#cmdcli-18) | moyenne | lisibilite | S | Pas d'état vide, pas d'état d'erreur, chargement en texte brut | `client/src/pages/CustomerOrders.tsx:829` |
|
||||
| [CMDCLI-21](#cmdcli-21) | moyenne | ux-simplicite | M | Formulaire peu guidant : fournisseur n°1 présélectionné, gencode « obligatoire » non exigé, recherche produit placée après la désignation | `client/src/components/CustomerOrderForm.tsx:115` |
|
||||
| [CMDCLI-22](#cmdcli-22) | moyenne | ux-simplicite | S | Une panne de l'API article est affichée comme « Produit non référencé » et bloque la création | `client/src/components/CustomerOrderForm.tsx:169` |
|
||||
| [CMDCLI-24](#cmdcli-24) | moyenne | lisibilite | M | Tableau de 9 colonnes sans défilement horizontal, informations clés absentes, texte barré illisible | `client/src/pages/CustomerOrders.tsx:832` |
|
||||
| [CMDCLI-25](#cmdcli-25) | moyenne | accessibilite | S | Éléments cliquables non accessibles : badge de statut, bandeau d'alerte, boutons-icônes sans libellé | `client/src/pages/CustomerOrders.tsx:882` |
|
||||
| [CMDCLI-26](#cmdcli-26) | moyenne | ux-simplicite | S | Changement de statut appliqué immédiatement, y compris « Annulé », sans confirmation (et menu mobile incomplet) | `client/src/pages/CustomerOrders.tsx:1037` |
|
||||
| [CMDCLI-29](#cmdcli-29) | moyenne | ux-simplicite | S | Modale Détails trompeuse : faux code-barres « scannable », bloc d'actions sans bouton, infos d'appel absentes | `client/src/components/CustomerOrderDetails.tsx:126` |
|
||||
| [CMDCLI-30](#cmdcli-30) | moyenne | bug | S | Le code-barres imprimé peut différer du gencode du produit | `client/src/pages/CustomerOrders.tsx:309` |
|
||||
| [CMDCLI-31](#cmdcli-31) | moyenne | ux-simplicite | S | Mobile : « Aucune commande trouvée » dès que « Tous les magasins » ou aucun magasin n'est sélectionné | `client/src/pages/mobile/CustomerOrdersPage.tsx:91` |
|
||||
| [CMDCLI-32](#cmdcli-32) | moyenne | ux-simplicite | M | Mobile : erreurs de validation invisibles et formulaire dupliqué du desktop | `client/src/pages/mobile/CustomerOrdersPage.tsx:54` |
|
||||
| [CMDCLI-33](#cmdcli-33) | moyenne | perf-client | M | Mobile : toutes les commandes (historique compris) rendues d'un bloc, sans filtre de statut ni pagination | `client/src/pages/mobile/CustomerOrdersPage.tsx:270` |
|
||||
| [CMDCLI-39](#cmdcli-39) | moyenne | dette-code | S | Logs de debug en production, dont des données personnelles clients | `server/routes.ts:3530` |
|
||||
| [CMDCLI-40](#cmdcli-40) | moyenne | perf-serveur | S | Réponses JSON non compressées et recopiées récursivement à chaque envoi | `server/routes.ts:168` |
|
||||
| [CMDCLI-41](#cmdcli-41) | moyenne | perf-api | S | Les « clients à appeler » sont rechargés par un 2e endpoint polled alors qu'ils sont déjà dans la liste | `client/src/pages/CustomerOrders.tsx:74` |
|
||||
| [CMDCLI-12](#cmdcli-12) | basse | perf-serveur | S | PUT/DELETE/mark-called chargent la commande avec 2 jointures (et le logo) juste pour lire groupId | `server/routes.ts:3585` |
|
||||
| [CMDCLI-13](#cmdcli-13) | basse | perf-client | S | Création : invalidateQueries + refetchQueries en double (y compris caches inactifs) | `client/src/pages/CustomerOrders.tsx:110` |
|
||||
| [CMDCLI-19](#cmdcli-19) | basse | perf-client | S | Changement de magasin : écran « Chargement... » au lieu de garder la liste précédente | `client/src/pages/CustomerOrders.tsx:96` |
|
||||
| [CMDCLI-23](#cmdcli-23) | basse | perf-api | M | Recherche article : 2 appels externes séquentiels via un proxy sans timeout ni cache | `server/routes.ts:6402` |
|
||||
| [CMDCLI-27](#cmdcli-27) | basse | lisibilite | S | Libellés incohérents, en anglais ou abrégés | `client/src/pages/CustomerOrders.tsx:808` |
|
||||
| [CMDCLI-28](#cmdcli-28) | basse | coherence-design | S | Couleurs et listes de statuts dupliquées et divergentes (3 copies) | `client/src/components/CustomerOrderDetails.tsx:13` |
|
||||
| [CMDCLI-34](#cmdcli-34) | basse | dette-code | S | Mobile : queryFn manuels sans contrôle d'erreur au lieu du fetcher par défaut | `client/src/pages/mobile/CustomerOrdersPage.tsx:105` |
|
||||
| [CMDCLI-35](#cmdcli-35) | basse | lisibilite | S | Dates et montants mal formatés | `client/src/pages/mobile/CustomerOrdersPage.tsx:328` |
|
||||
| [CMDCLI-36](#cmdcli-36) | basse | accessibilite | S | Mobile : feuille « contact client » faite main (pas de focus, pas d'Échap, pas de rôle dialog) | `client/src/pages/mobile/CustomerOrdersPage.tsx:628` |
|
||||
| [CMDCLI-37](#cmdcli-37) | basse | ux-simplicite | S | Deux parcours différents pour « client contacté » (avec et sans commentaire) et double défilement | `client/src/components/modals/ClientCallsModal.tsx:25` |
|
||||
| [CMDCLI-38](#cmdcli-38) | basse | ux-simplicite | S | Mise en page chargée et tri incohérent (Retiré en fin de liste mais pas Annulé, tri non modifiable) | `client/src/pages/CustomerOrders.tsx:680` |
|
||||
| [CMDCLI-42](#cmdcli-42) | basse | perf-bundle | S | JsBarcode et la page importés en statique alors qu'ils ne servent qu'à l'impression | `client/src/pages/CustomerOrders.tsx:33` |
|
||||
| [CMDCLI-43](#cmdcli-43) | basse | dette-code | S | Code mort et contournements dans la page et le formulaire | `client/src/pages/CustomerOrders.tsx:69` |
|
||||
|
||||
### CMDCLI-01
|
||||
|
||||
**Chaque commande embarque la fiche magasin complète (logo base64, SMTP, NocoDB) et la fiche fournisseur complète** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:1873`
|
||||
- **Constat :** storage.ts:1870-1874 `.select({ customerOrder: customerOrders, supplier: suppliers, group: groups })` (idem getPendingClientCalls storage.ts:1945-1949 et getCustomerOrder 1894-1898). shared/schema.ts:67 `logo: text("logo"), // Logo en data URI (data:image/png;base64,...)` ; Groups.tsx:39 `const MAX_LOGO_KB = 200;` ; schema.ts:52-76 nocodb*, webhookUrl, smtpHost, smtpUser... Le client n'utilise que group.name/color et supplier.id/name.
|
||||
- **Impact :** Le logo (jusqu'à ~270 Ko en base64) est répété pour CHAQUE commande : 300 commandes d'un magasin avec logo = plusieurs dizaines de Mo de JSON non compressé, téléchargés à l'ouverture de la page, après chaque mutation, sur le Dashboard, et toutes les 30 s pour pending-calls. Expose aussi la config SMTP/NocoDB/webhook aux employés.
|
||||
- **Recommandation :** Sélectionner explicitement les colonnes utiles : `group: { id: groups.id, name: groups.name, color: groups.color }, supplier: { id: suppliers.id, name: suppliers.name }` dans getCustomerOrders, getCustomerOrder et getPendingClientCalls ; supprimer le mapping mort `creator: row.creator || {...}` (storage.ts:1886, row.creator n'est jamais sélectionné).
|
||||
|
||||
### CMDCLI-02
|
||||
|
||||
**Liste sans pagination ni filtre serveur : tout l'historique est téléchargé puis filtré/trié/paginé en JS** — perf-api, sévérité haute, effort L
|
||||
|
||||
- **Fichier :** `server/routes.ts:3508`
|
||||
- **Constat :** routes.ts:3508 `const customerOrders = await storage.getCustomerOrders(groupIds);` sans limit/offset/status ; CustomerOrders.tsx:657-734 filtre, tri puis `usePagination(sortedOrders, 10)` côté client ; Dashboard.tsx:92-93 recharge la même liste et 381-386 ne fait que `customerOrders.filter(o => o.status === '...').length`.
|
||||
- **Impact :** Le temps de chargement croît indéfiniment avec l'historique (les commandes Retiré/Annulé ne sont jamais purgées de la réponse) alors que l'utilisateur ne voit que 10 lignes ; le Dashboard télécharge toute la liste pour afficher 5 compteurs.
|
||||
- **Recommandation :** Ajouter `?status=&supplierId=&q=&page=&limit=` à GET /api/customer-orders (WHERE + ILIKE + LIMIT/OFFSET + COUNT(*) OVER()), exclure par défaut Retiré/Annulé de plus de 30 jours, et créer GET /api/customer-orders/stats (SELECT status, COUNT(*) GROUP BY status) pour le Dashboard. Côté client : queryKey ['/api/customer-orders', {storeId, status, supplierId, q, page}] + placeholderData: keepPreviousData.
|
||||
|
||||
### CMDCLI-03
|
||||
|
||||
**Modifier une commande remet toujours son statut à « En attente de Commande »** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderForm.tsx:143`
|
||||
- **Constat :** CustomerOrderForm.tsx:116 `status: "En attente de Commande", // Statut fixe` (même en mode édition, aucun champ statut dans le formulaire) puis :143 `status: data.status || "En attente de Commande",` envoyé par handleEditOrder -> PUT /api/customer-orders/:id (CustomerOrders.tsx:257-260).
|
||||
- **Impact :** Corriger un simple numéro de téléphone sur une commande « Disponible » la renvoie en « En attente de Commande » : elle disparaît du bandeau « clients à appeler » et le client risque de ne jamais être prévenu.
|
||||
- **Recommandation :** En mode édition, ne pas envoyer `status` (ou utiliser `order.status` en defaultValue) ; n'imposer « En attente de Commande » qu'à la création (`...(order ? {} : { status: 'En attente de Commande' })`).
|
||||
|
||||
### CMDCLI-04
|
||||
|
||||
**Modifier une commande peut la déplacer dans un autre magasin** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderForm.tsx:127`
|
||||
- **Constat :** CustomerOrderForm.tsx:127 `const groupId = getUserAssignedGroupId();` puis :148 `groupId: groupId,` — ignore `order.groupId`. getUserAssignedGroupId (86-103) renvoie `user.userGroups[0].groupId`, sinon selectedStoreId, sinon `groups[0].id`. Le serveur applique tel quel : routes.ts:3599 `storage.updateCustomerOrder(id, req.body)`.
|
||||
- **Impact :** Un directeur multi-magasins ou un admin (vue « tous magasins ») qui édite une commande du magasin B la réaffecte silencieusement à son premier magasin : la commande disparaît de la liste du bon magasin.
|
||||
- **Recommandation :** Auto : dans handleSubmit, `const groupId = order?.groupId ?? getUserAssignedGroupId();`. Non auto, à traiter avec CMDCLI-07 : côté serveur, ignorer ou valider groupId dans le PUT. À noter aussi : à la création, un directeur multi-magasins crée dans userGroups[0] même si un autre magasin est sélectionné (selectedStoreId n'est pris en compte que pour l'admin).
|
||||
|
||||
### CMDCLI-07
|
||||
|
||||
**Permissions non appliquées côté serveur et corps de requête non validé (PUT/POST/DELETE)** — bug, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `server/routes.ts:3599`
|
||||
- **Constat :** shared/permissions.ts:69-74 : manager sans 'delete', employee seulement ['view','create']. Or routes.ts:3577-3605 (PUT) et 3607-3635 (DELETE) ne vérifient que l'appartenance au magasin, pas le rôle ; :3524-3527 `const data = { ...req.body, createdBy: user.id }` et :3599 `updateCustomerOrder(id, req.body)` sans Zod (insertCustomerOrderSchema importé :127 mais jamais utilisé). Côté client, bouton Modifier (CustomerOrders.tsx:901-907) et badge statut cliquable (881-887) affichés sans `permissions.canEdit`, menu ⋮ mobile (339-356) idem.
|
||||
- **Impact :** Un employé peut modifier/annuler n'importe quelle commande, un manager peut supprimer via l'API ; un body arbitraire peut changer groupId/createdBy/createdAt ; une donnée invalide provoque un 500 générique.
|
||||
- **Recommandation :** Sans risque : `requireModulePermission('customer-orders','delete')` sur DELETE (le bouton est déjà masqué par canDelete, CustomerOrders.tsx:933). Pour le PUT : schéma Zod dédié (champs éditables, deposit en z.coerce.number().transform(String), notifiedAt et notifiedComment nullables, sans groupId ni createdBy), et décider avec le métier si les employés gardent le changement de statut ou de contact avant d'imposer 'edit'. Côté client, masquer Modifier selon canEdit.
|
||||
|
||||
### CMDCLI-08
|
||||
|
||||
**Impression d'étiquette : injection HTML/JS (XSS) via les champs de la commande** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:388`
|
||||
- **Constat :** CustomerOrders.tsx:383 `window.open('', '_blank')` puis :388 `printWindow.document.write(` avec interpolation brute `${order.customerName}` (560), `${order.productDesignation}` (583), `${order.productReference}` (589), `${order.orderTaker}` (634), `${order.group.name}` (639). La fenêtre about:blank hérite de l'origine de l'application.
|
||||
- **Impact :** Un nom client du type `<img src=x onerror=...>` saisi par un collègue s'exécute avec la session de celui qui imprime (appels API en son nom). Un simple « < » ou « & » casse aussi la mise en page de l'étiquette.
|
||||
- **Recommandation :** Échapper toutes les valeurs (fonction escapeHtml remplaçant & < > " ') avant interpolation, ou construire la fenêtre via DOM (textContent).
|
||||
|
||||
### CMDCLI-20
|
||||
|
||||
**Formulaire de commande réduit à 90 % par transform et modale sans hauteur max : texte petit, bouton Créer hors écran** — ux-simplicite, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:971`
|
||||
- **Constat :** CustomerOrders.tsx:967 `<DialogContent className="max-w-3xl" style={{ height: 'auto', maxHeight: 'none' }}>` puis :971 `<div style={{ transform: 'scale(0.9)', transformOrigin: 'top left', width: '111%' }}>` (idem 983-987 en édition). Le formulaire empile ~10 champs + 3 titres sur une colonne (CustomerOrderForm.tsx:220-435).
|
||||
- **Impact :** Sur un portable 1366×768, la modale centrée dépasse l'écran sans barre de défilement : haut et bouton « Créer » inaccessibles ; texte réduit (et parfois flou) pour des utilisateurs non techniciens.
|
||||
- **Recommandation :** Auto : supprimer le wrapper transform et le style inline, et utiliser `className="max-w-3xl max-h-[90vh] overflow-y-auto"` sur les deux DialogContent. Non auto, choix de design : grille 2 colonnes et pied de modale collant.
|
||||
|
||||
### CMDCLI-05
|
||||
|
||||
**Modifier une commande efface ses notes et l'email client ; les notes ne sont affichées nulle part** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderForm.tsx:147`
|
||||
- **Constat :** CustomerOrderForm.tsx:107-121 defaultValues sans `notes` ni `customerEmail` (aucun champ dans le formulaire), puis :137 `customerEmail: data.customerEmail || ''` et :147 `notes: data.notes || ''`. La version mobile saisit pourtant des « Notes internes » (mobile/CustomerOrdersPage.tsx:593-604). CustomerOrderDetails.tsx n'affiche ni notes, ni notifiedAt, ni notifiedComment.
|
||||
- **Impact :** Les notes saisies sur téléphone sont invisibles sur PC puis écrasées à la première modification.
|
||||
- **Recommandation :** Partie sûre et invisible : ajouter `notes: order?.notes || ''` et `customerEmail: order?.customerEmail || ''` aux defaultValues. RHF conserve les valeurs par défaut des champs non enregistrés et le schéma Zod (43-44) les garde, donc l'édition n'efface plus rien. Ajouter un Textarea « Notes internes » et l'affichage « Contacté le … » dans les Détails change l'interface : à valider séparément.
|
||||
|
||||
### CMDCLI-06
|
||||
|
||||
**Mobile : les cases « Prix promotionnel / Pub » et « Client déjà notifié » n'ont aucun effet** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:67`
|
||||
- **Constat :** Le schéma Zod (67-78) ne déclare ni isPromotionalPrice ni customerNotified ; les champs sont forcés avec `name={"isPromotionalPrice" as any}` (555) et `name={"customerNotified" as any}` (575). zodResolver renvoie les valeurs parsées (z.object retire les clés inconnues), donc :263-264 `isPromotionalPrice: data.isPromotionalPrice, customerNotified: data.customerNotified` valent undefined.
|
||||
- **Impact :** Le vendeur coche « Prix publicité » sur téléphone mais la commande est enregistrée au prix normal (valeur par défaut false) : litige possible au retrait.
|
||||
- **Recommandation :** Ajouter `isPromotionalPrice: z.boolean().default(false), customerNotified: z.boolean().default(false)` au schéma et aux defaultValues, puis retirer les `as any`. Pour la factorisation, voir CMDCLI-32. Option produit : masquer « Client déjà notifié » à la création comme sur desktop, car notifiedAt reste null.
|
||||
|
||||
### CMDCLI-09
|
||||
|
||||
**Polling pending-calls lancé pour les employés alors que le serveur répond 403 (avec 2 retries toutes les 30 s)** — perf-api, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:74`
|
||||
- **Constat :** CustomerOrders.tsx:74-87 useQuery sans `enabled`, `refetchInterval: 30000` ; routes.ts:3648-3650 `if (user.role === 'employee') { return res.status(403)... }` ; queryClient.ts retry `return failureCount < 2;` pour toute erreur non-401.
|
||||
- **Impact :** Chaque poste employé ouvert sur la page génère 3 requêtes en échec toutes les 30 s (chacune = 4 requêtes SQL d'auth, voir CMDCLI-11) pour rien.
|
||||
- **Recommandation :** `enabled: !!user && user.role !== 'employee'` (ou permission dédiée), et `refetchIntervalInBackground: false` explicite.
|
||||
|
||||
### CMDCLI-10
|
||||
|
||||
**Mutations sans gestion d'erreur : échecs silencieux (statut, contact client)** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:171`
|
||||
- **Constat :** CustomerOrders.tsx:171-181 statusMutation, 184-194 markCalledMutation, 197-206 unmarkCalledMutation : aucun `onError`. Idem mobile 122-129, 131-140, 142-150. Or routes.ts:3684-3686 renvoie 403 aux employés pour mark-called, alors que le bouton téléphone (CustomerOrders.tsx:917-932) et « Marquer contacté » (mobile 369-375) leur sont affichés.
|
||||
- **Impact :** Un employé clique « Confirmer » : rien ne se passe, la modale reste ouverte, aucun message. Toute erreur réseau sur un changement de statut est invisible.
|
||||
- **Recommandation :** Auto : ajouter un onError avec un toast destructive en français (« Impossible de changer le statut », « Impossible d'enregistrer le contact ») sur les 6 mutations. Non auto : masquer le bouton contact pour les employés ou les autoriser côté serveur (décision produit).
|
||||
|
||||
### CMDCLI-11
|
||||
|
||||
**getUserWithGroups rappelé dans chaque handler alors que deserializeUser l'a déjà chargé (4 requêtes SQL séquentielles par appel)** — perf-serveur, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3488`
|
||||
- **Constat :** localAuth.ts:141-148 `passport.deserializeUser(... storage.getUserWithGroups(id) ...)` ; puis routes.ts:3488, 3519, 3579, 3609, 3640, 3676 `const user = await storage.getUserWithGroups(req.user.claims ? ... : req.user.id);`. storage.ts:371-386 : getUser puis requête user_groups JOIN groups, séquentielles.
|
||||
- **Impact :** Chaque appel /api/customer-orders* (dont le polling 30 s) paie 4 allers-retours BD avant le travail utile ; latence ajoutée à chaque action.
|
||||
- **Recommandation :** Utiliser `const user = req.user as UserWithGroups` dans ces handlers ; dans getUserWithGroups, faire une seule requête users LEFT JOIN user_groups JOIN groups (ou Promise.all).
|
||||
|
||||
### CMDCLI-14
|
||||
|
||||
**Chaque changement de statut/contact recharge toute la liste au lieu de mettre à jour la ligne** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:175`
|
||||
- **Constat :** CustomerOrders.tsx:132, 153, 175, 188, 201 `queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') })` ; le PUT renvoie pourtant la commande mise à jour (routes.ts:3600 `res.json(updatedOrder)`).
|
||||
- **Impact :** Latence visible (aller-retour PUT + re-téléchargement de toute la liste) à chaque clic de statut ; l'utilisateur ne voit pas de retour immédiat.
|
||||
- **Recommandation :** Mise à jour optimiste : onMutate -> `queryClient.setQueryData(key, old => old.map(o => o.id === id ? { ...o, status } : o))`, rollback onError, puis invalidation ciblée de pending-calls uniquement.
|
||||
|
||||
### CMDCLI-15
|
||||
|
||||
**« Marquer Appelé » depuis la modale ne rafraîchit pas le tableau pour un admin avec magasin sélectionné** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/modals/ClientCallsModal.tsx:42`
|
||||
- **Constat :** ClientCallsModal.tsx:42 `queryClient.invalidateQueries({ queryKey: ['/api/customer-orders'] });` alors que la clé du tableau est CustomerOrders.tsx:95-97 `[`/api/customer-orders?storeId=${selectedStoreId}`, selectedStoreId]` pour un admin : le préfixe ne correspond pas.
|
||||
- **Impact :** Le client est marqué appelé mais l'icône téléphone reste grise dans le tableau (staleTime 30 s, pas de refetch au focus) : risque de double appel.
|
||||
- **Recommandation :** Correction minimale et sûre dans ClientCallsModal.tsx:37-42 : remplacer les deux invalidations par `queryClient.invalidateQueries({ predicate: q => String(q.queryKey[0] ?? '').startsWith('/api/customer-orders') })` (couvre aussi pending-calls). L'unification des clés reste à planifier avec CMDCLI-02.
|
||||
|
||||
### CMDCLI-16
|
||||
|
||||
**Le sélecteur de magasin n'a aucun effet pour directeur/manager (et clé de cache partagée avec le mobile)** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:95`
|
||||
- **Constat :** CustomerOrders.tsx:95 `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}` — le serveur sait pourtant filtrer pour les non-admins (routes.ts:3500-3501). Le tableau n'a pas de colonne Magasin. Mobile 92-96 utilise la même clé `['/api/customer-orders', selectedStoreId]` mais avec ?storeId.
|
||||
- **Impact :** Un directeur multi-magasins choisit « Magasin B » et voit les commandes de tous ses magasins mélangées sans pouvoir les distinguer ; en passant de l'affichage mobile au desktop, le cache affiche des données filtrées différemment.
|
||||
- **Recommandation :** Toujours passer `storeId` quand un magasin est sélectionné (quel que soit le rôle) et inclure le paramètre dans la clé ; afficher une colonne Magasin quand aucun magasin n'est sélectionné.
|
||||
|
||||
### CMDCLI-17
|
||||
|
||||
**Filtre + tri de toute la liste recalculés à chaque rendu (y compris à chaque frappe dans le commentaire d'appel)** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:657`
|
||||
- **Constat :** CustomerOrders.tsx:657-675 `customerOrders.filter(...)` avec `searchTerm.toLowerCase()` recalculé 5 fois par commande ; 678-723 tri avec `safeDate(a.createdAt)` parsé à chaque comparaison ; aucun useMemo ni debounce. L'état `contactComment` (61) vit dans ce composant : chaque caractère tapé dans la modale contact relance filtre + tri de toutes les commandes.
|
||||
- **Impact :** Saisie qui rame sur de grosses listes ou des PC de magasin modestes.
|
||||
- **Recommandation :** Auto : useMemo sur filteredOrders et sortedOrders ([customerOrders, searchTerm, filterSupplier, filterStatus, sortBy, sortOrder]), `const q = searchTerm.toLowerCase()` calculé une fois, timestamps pré-calculés. Optionnel, refactor : useDeferredValue et extraction de la modale contact.
|
||||
|
||||
### CMDCLI-18
|
||||
|
||||
**Pas d'état vide, pas d'état d'erreur, chargement en texte brut** — lisibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:829`
|
||||
- **Constat :** CustomerOrders.tsx:829-831 `{isLoading ? (<div>Chargement...</div>) : (<Table>...` ; 847 `paginatedOrders.map(...)` sans branche « aucune commande » ; aucune lecture de `isError`. Côté serveur routes.ts:3513 `res.status(500).json([])`.
|
||||
- **Impact :** Sans commande ou en cas de panne, l'utilisateur voit un tableau vide avec seulement les en-têtes et ne sait pas si c'est normal, filtré ou cassé.
|
||||
- **Recommandation :** Skeleton de 5 lignes pendant le chargement ; ligne « Aucune commande ne correspond à votre recherche » + bouton « Effacer les filtres » ; Alert rouge « Impossible de charger les commandes — Réessayer » si isError.
|
||||
|
||||
### CMDCLI-21
|
||||
|
||||
**Formulaire peu guidant : fournisseur n°1 présélectionné, gencode « obligatoire » non exigé, recherche produit placée après la désignation** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderForm.tsx:115`
|
||||
- **Constat :** CustomerOrderForm.tsx:115 `supplierId: order?.supplierId || 1` et 272 `value={field.value?.toString() || "1"}` ; 344 `<FormLabel>Gencode (obligatoire)</FormLabel>` alors que 36 `gencode: z.string().optional()` ; ordre : Fournisseur (266) avant Produit, Désignation (295) avant Gencode (339) alors que la loupe gencode remplit désignation, référence et fournisseur (175-200) ; téléphone optionnel (33) alors qu'il sert à rappeler le client.
|
||||
- **Impact :** Commandes enregistrées chez le mauvais fournisseur sans que le vendeur s'en rende compte ; saisie manuelle inutile de la désignation ; commandes sans téléphone impossibles à rappeler.
|
||||
- **Recommandation :** Placer « Scanner / saisir le gencode » en premier avec autofocus et recherche à la touche Entrée ; fournisseur vide par défaut et requis (placeholder « Choisir un fournisseur ») ; aligner libellé et validation du gencode ; rendre le téléphone obligatoire (décision produit).
|
||||
|
||||
### CMDCLI-22
|
||||
|
||||
**Une panne de l'API article est affichée comme « Produit non référencé » et bloque la création** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderForm.tsx:169`
|
||||
- **Constat :** CustomerOrderForm.tsx:169 `if (!res.ok) { setArticleNotFound(true); return; }` et 202 `catch { setArticleNotFound(true); }` ; 438-441 « Produit non référencé — … La commande ne peut pas être créée. » ; 456 `disabled={... || (articleNotFound && !order) || ...}`. Identique mobile 194-227, 606-611.
|
||||
- **Impact :** Si api.ffnancy.fr est lent ou indisponible, le vendeur ne peut plus créer la commande et reçoit un message faux ; il doit fermer et rouvrir la modale.
|
||||
- **Recommandation :** Distinguer 3 cas : introuvable (404/aucun article), erreur technique (« Recherche indisponible, saisissez le produit manuellement ») qui ne bloque pas, et réinitialiser articleNotFound dès que l'utilisateur modifie le gencode.
|
||||
|
||||
### CMDCLI-24
|
||||
|
||||
**Tableau de 9 colonnes sans défilement horizontal, informations clés absentes, texte barré illisible** — lisibilite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:832`
|
||||
- **Constat :** CustomerOrders.tsx:835-843 colonnes Client, Téléphone, Produit, Quantité, Fournisseur, Gencode, Statut, Date, Actions ; ui/table.tsx:9 wrapper `<div className="relative w-full">` sans overflow-auto ; 852-889 `line-through` appliqué à chaque cellule (et au badge) des commandes Retiré/Annulé ; acompte et « prix publicité » non affichés ; 908-916 bouton Imprimer présent seulement si Disponible (colonne Actions irrégulière).
|
||||
- **Impact :** Sur écran 1280 px avec sidebar, le tableau déborde ; au comptoir, l'information utile (acompte à déduire) exige d'ouvrir le détail ; les lignes barrées sont difficiles à lire.
|
||||
- **Recommandation :** Fusionner Client + Téléphone, remplacer Gencode par Acompte, ajouter `overflow-x-auto` au wrapper, griser (opacity-60) sans barrer, et regrouper les actions secondaires dans un menu « ⋯ » à côté d'un bouton principal contextuel (« Prévenir le client » si Disponible, « Remis au client » ensuite).
|
||||
|
||||
### CMDCLI-25
|
||||
|
||||
**Éléments cliquables non accessibles : badge de statut, bandeau d'alerte, boutons-icônes sans libellé** — accessibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:882`
|
||||
- **Constat :** CustomerOrders.tsx:882-887 `<Badge ... onClick={() => openStatusModal(order)}>` (Badge = `<div>`, ui/badge.tsx:32) ; 740-743 `<Alert ... onClick={() => setShowClientCallsModal(true)}>` ; 894-941 boutons Eye/Edit/Printer/Trash2 sans `title` ni `aria-label` ; loupes du formulaire CustomerOrderForm.tsx:322-331 et 348-357 idem.
|
||||
- **Impact :** Changement de statut impossible au clavier et non découvrable ; l'utilisateur doit deviner la fonction de chaque icône.
|
||||
- **Recommandation :** Remplacer le badge cliquable par un `<button>` (ou un Select de statut), rendre le bandeau un `<button>` ; ajouter `aria-label` + `title` (« Voir le détail », « Modifier », « Imprimer l'étiquette », « Supprimer », « Rechercher le produit »).
|
||||
|
||||
### CMDCLI-26
|
||||
|
||||
**Changement de statut appliqué immédiatement, y compris « Annulé », sans confirmation (et menu mobile incomplet)** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:1037`
|
||||
- **Constat :** CustomerOrders.tsx:1037-1042 `onClick={() => { handleStatusChange(selectedOrder.id, status); setShowStatusModal(false); }}` pour les 5 statuts dont « Annulé » ; mobile 346-354 menu ⋮ : « Marquer Disponible », « Marquer Retiré », « Annuler » (libellé ambigu, sans confirmation), pas d'option « Commande en Cours », proposé même sur une commande déjà Retiré.
|
||||
- **Impact :** Un clic de travers annule une commande client sans possibilité d'annulation ; sur mobile « Annuler » peut être compris comme « fermer le menu ».
|
||||
- **Recommandation :** Confirmation (ConfirmationModal) pour Annulé/Retiré, toast avec bouton « Annuler l'action » ; sur mobile libellé « Annuler la commande » en rouge séparé, et proposer uniquement le statut suivant logique.
|
||||
|
||||
### CMDCLI-29
|
||||
|
||||
**Modale Détails trompeuse : faux code-barres « scannable », bloc d'actions sans bouton, infos d'appel absentes** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderDetails.tsx:126`
|
||||
- **Constat :** CustomerOrderDetails.tsx:126 « Code-barres EAN13 scannable » au-dessus d'un SVG de barres codées en dur (129-167), identique pour tous les produits ; 237-246 « Actions disponibles : • Étiquette imprimable disponible • Notification client possible » sans aucun bouton ; renderBarcode (31-42) jamais appelé ; notes, notifiedAt et notifiedComment non affichés ; 79 « Client notifié » affiché seulement si statut Disponible.
|
||||
- **Impact :** Un vendeur peut tenter de scanner un code-barres factice ; l'utilisateur doit fermer la modale pour agir ; l'historique d'appel est invisible.
|
||||
- **Recommandation :** Supprimer le faux SVG (afficher le numéro ou générer un vrai code avec JsBarcode en lazy) et le code mort ; remplacer le bloc texte par de vrais boutons (Imprimer, Prévenir, Modifier) ; afficher « Contacté le … — commentaire » et les notes.
|
||||
|
||||
### CMDCLI-30
|
||||
|
||||
**Le code-barres imprimé peut différer du gencode du produit** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:309`
|
||||
- **Constat :** CustomerOrders.tsx:309-315 code complété `padStart(12, '0')` s'il est court, tronqué à 12 chiffres sinon, puis :318-319 checksum recalculé ; 618 `<div class="barcode-number">${order.gencode}</div>` affiche le gencode original sous un code-barres encodant un autre numéro (EAN-8, codes internes, checksum invalide).
|
||||
- **Impact :** À la caisse, le scan de l'étiquette peut renvoyer un autre article ou aucun.
|
||||
- **Recommandation :** Encoder le gencode tel quel avec le format adapté (EAN13 si 13 chiffres valides, EAN8 si 8, sinon CODE128) et afficher sous l'image la valeur réellement encodée.
|
||||
|
||||
### CMDCLI-31
|
||||
|
||||
**Mobile : « Aucune commande trouvée » dès que « Tous les magasins » ou aucun magasin n'est sélectionné** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:91`
|
||||
- **Constat :** mobile 94 `if (!selectedStoreId) return [];` et 102 `enabled: !!selectedStoreId && !!user` ; 312-316 affiche « Aucune commande trouvée ». MobileLayout.tsx:152-159 propose « Tous les magasins » à l'admin et l'en-tête affiche « Tous les magasins » par défaut (28) ; MobileApp.tsx:36-41 n'auto-sélectionne un magasin que pour directeur/manager mono-magasin (pas les employés).
|
||||
- **Impact :** L'utilisateur croit qu'il n'y a aucune commande alors qu'il doit d'abord choisir un magasin.
|
||||
- **Recommandation :** Auto : quand !selectedStoreId, afficher un état dédié « Choisissez un magasin pour voir ses commandes » (avec ouverture du menu ou sélecteur) à la place de « Aucune commande trouvée ». Non auto : charger toutes les commandes autorisées, ou auto-sélectionner le magasin unique d'un employé.
|
||||
|
||||
### CMDCLI-32
|
||||
|
||||
**Mobile : erreurs de validation invisibles et formulaire dupliqué du desktop** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:54`
|
||||
- **Constat :** `FormMessage` importé (54) mais jamais rendu dans les FormField 409-604 ; orderTaker initialisé à `user?.username` (180, identifiant de connexion) contre `user?.name` sur desktop (CustomerOrderForm.tsx:108) ; schéma Zod, recherche article (190-240) et logique groupId recopiés de CustomerOrderForm.tsx ; la case « Client déjà notifié » est proposée à la création alors que le desktop la masque (CustomerOrderForm.tsx:434).
|
||||
- **Impact :** Si un champ requis manque, le bouton « Valider la commande » ne fait rien de visible ; deux formulaires à maintenir qui divergent déjà (voir CMDCLI-06).
|
||||
- **Recommandation :** Ajouter `<FormMessage />` à chaque champ, puis factoriser : réutiliser CustomerOrderForm (prop `variant="mobile"`) et un hook `useArticleLookup` partagé.
|
||||
|
||||
### CMDCLI-33
|
||||
|
||||
**Mobile : toutes les commandes (historique compris) rendues d'un bloc, sans filtre de statut ni pagination** — perf-client, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:270`
|
||||
- **Constat :** mobile 270-274 `orders.filter(...)` sans useMemo ni filtre de statut ; 318-389 rend une Card + un DropdownMenu Radix par commande, Retiré/Annulé mélangées aux actives (tri serveur par date).
|
||||
- **Impact :** Liste interminable et lente sur téléphone ; les commandes à traiter sont noyées dans l'historique.
|
||||
- **Recommandation :** Onglets/puces « À traiter / Disponibles / Terminées » (par défaut : hors Retiré/Annulé), useMemo, chargement par pages de 20 (« Voir plus ») une fois la pagination serveur en place (CMDCLI-02).
|
||||
|
||||
### CMDCLI-39
|
||||
|
||||
**Logs de debug en production, dont des données personnelles clients** — dette-code, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:3530`
|
||||
- **Constat :** Serveur routes.ts:3530-3531 `console.log('🔍 CUSTOMER ORDER - Received data from frontend:', req.body)` (nom + téléphone client), 3532-3566 ~10 autres logs, 3507/3509 à chaque GET, 3666 à chaque poll pending-calls. Client CustomerOrders.tsx:83 (toutes les 30 s), 103, 107, 118, 246-254, 321.
|
||||
- **Impact :** Journaux serveur gonflés et contenant des données personnelles (RGPD) ; console navigateur polluée ; léger coût sur les chemins chauds.
|
||||
- **Recommandation :** Auto : supprimer, ou conditionner à process.env.DEBUG_CUSTOMER_ORDERS, les console.log serveur de routes.ts:3507, 3509, 3530-3566 et 3666 ; ne jamais logguer req.body. Le nettoyage des console.log client est cosmétique (déjà retirés du build de production).
|
||||
|
||||
### CMDCLI-40
|
||||
|
||||
**Réponses JSON non compressées et recopiées récursivement à chaque envoi** — perf-serveur, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/routes.ts:168`
|
||||
- **Constat :** routes.ts:165-170 `res.json = (body) => originalJson(stripSmtpPassword(body))` parcourt et recopie chaque objet de la liste (sanitize.ts:13-40) ; aucun middleware de compression dans server/index.ts / index.production.ts, et `setupCompression` (server/cache.ts:93-101) n'est jamais appelé (et ne compresse rien : il pose seulement l'en-tête).
|
||||
- **Impact :** La liste des commandes (déjà alourdie, CMDCLI-01) part en clair sur le réseau du magasin ; CPU serveur consommé à recopier chaque ligne.
|
||||
- **Recommandation :** Vérifier d'abord la configuration gzip du nginx frontal. Si elle est absente : `npm i compression` et `app.use(compression())` avant registerRoutes dans index.ts et index.production.ts. Conserver stripSmtpPassword (filet de sécurité global). Supprimer setupCompression, mort et trompeur.
|
||||
|
||||
### CMDCLI-41
|
||||
|
||||
**Les « clients à appeler » sont rechargés par un 2e endpoint polled alors qu'ils sont déjà dans la liste** — perf-api, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:74`
|
||||
- **Constat :** CustomerOrders.tsx:74-87 GET pending-calls toutes les 30 s ; storage.ts:1931-1935 `customerNotified = false AND status IN ('Disponible', ...)` — sous-ensemble exact des commandes déjà chargées par la requête de la ligne 96 (même périmètre de magasins).
|
||||
- **Impact :** Double transfert des mêmes données (avec jointures lourdes) et 2 requêtes BD + 4 requêtes d'auth toutes les 30 s par poste ouvert.
|
||||
- **Recommandation :** Si l'on dérive : `const pendingCalls = useMemo(() => user?.role === 'employee' ? [] : customerOrders.filter(o => PENDING_STATUSES.includes(o.status) && !o.customerNotified), ...)` avec la même liste de statuts que storage.ts:1934, et garder un rafraîchissement de la liste (refetchInterval 60 s, onglet visible) une fois la liste allégée (CMDCLI-01/02).
|
||||
|
||||
### CMDCLI-12
|
||||
|
||||
**PUT/DELETE/mark-called chargent la commande avec 2 jointures (et le logo) juste pour lire groupId** — perf-serveur, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:3585`
|
||||
- **Constat :** routes.ts:3585, 3615, 3689 `const customerOrder = await storage.getCustomerOrder(id);` puis seul `customerOrder.groupId` est utilisé ; storage.ts:1892-1911 jointure suppliers + groups complète.
|
||||
- **Impact :** Requête et transfert inutiles à chaque changement de statut ou contact ; s'ajoute au refetch complet de la liste.
|
||||
- **Recommandation :** Créer `getCustomerOrderGroupId(id)` (SELECT group_id) ou faire `UPDATE ... WHERE id = $1 AND group_id = ANY($2) RETURNING *` et renvoyer 404/403 si 0 ligne.
|
||||
|
||||
### CMDCLI-13
|
||||
|
||||
**Création : invalidateQueries + refetchQueries en double (y compris caches inactifs)** — perf-client, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:110`
|
||||
- **Constat :** CustomerOrders.tsx:109 `queryClient.invalidateQueries({ predicate: ... includes('/api/customer-orders') })` puis :110 `queryClient.refetchQueries({ predicate: ... })` — refetchQueries recharge aussi les clés inactives (autres magasins visités, clé mobile, Dashboard).
|
||||
- **Impact :** Après chaque création, la liste complète (lourde, voir CMDCLI-01) est téléchargée 2 fois ou plus.
|
||||
- **Recommandation :** Supprimer la ligne 110 ; invalidateQueries suffit (refetch des requêtes actives, les autres marquées stale).
|
||||
|
||||
### CMDCLI-19
|
||||
|
||||
**Changement de magasin : écran « Chargement... » au lieu de garder la liste précédente** — perf-client, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:96`
|
||||
- **Constat :** CustomerOrders.tsx:96-98 useQuery sans `placeholderData` ; la clé change avec selectedStoreId donc `isLoading` repasse à true et le tableau est remplacé par le texte (829-831).
|
||||
- **Impact :** Clignotement et perte de repère à chaque changement de magasin.
|
||||
- **Recommandation :** `placeholderData: keepPreviousData` + indicateur discret `isFetching` dans l'en-tête de la carte.
|
||||
|
||||
### CMDCLI-23
|
||||
|
||||
**Recherche article : 2 appels externes séquentiels via un proxy sans timeout ni cache** — perf-api, sévérité basse, effort M
|
||||
|
||||
- **Fichier :** `server/routes.ts:6402`
|
||||
- **Constat :** Client CustomerOrderForm.tsx:168 puis 182-185 (article puis `/api/ffnancy/mouvements/entrees?artNoId=...&limit=1&dateDebut=2000-01-01`) ; serveur routes.ts:6402 `await fetch(`https://api.ffnancy.fr/api/articles?${params}`)` et 6417 sans AbortController ni mise en cache.
|
||||
- **Impact :** Recherche lente (2 allers-retours navigateur→serveur→ffnancy) et spinner potentiellement infini qui garde le bouton Créer désactivé (456).
|
||||
- **Recommandation :** Créer un endpoint unique `/api/ffnancy/article-lookup?ean=` qui enchaîne les 2 appels côté serveur avec timeout 5 s (AbortController) et cache mémoire 10 min (server/cache.ts).
|
||||
|
||||
### CMDCLI-27
|
||||
|
||||
**Libellés incohérents, en anglais ou abrégés** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:808`
|
||||
- **Constat :** CustomerOrders.tsx:805/808 placeholder et option « Status » (anglais) ; 792/795 option « Fournisseurs » pour « tous » ; titres « Commandes Client » (761, Sidebar.tsx:300) vs « Commandes Clients » (mobile 292) vs « Cmd Client » (MobileBottomNav.tsx:39) ; « Marquer Appelé » (ClientCallsModal.tsx:142) vs « Marquer client comme contacté » (1081) vs « Marquer contacté » (mobile 374) ; « Prix publicité » (form 424) vs « Prix promotionnel / Pub » (mobile 566) ; mobile « Qty: » (378), « Qté » (488), placeholder « Client, produit, rév... » (299, faute pour réf.) ; « Gencode » partout.
|
||||
- **Impact :** Vocabulaire déroutant pour des employés non techniciens ; impression d'application non finie.
|
||||
- **Recommandation :** Auto, corrections évidentes uniquement : « Status » en « Tous les statuts » (placeholder et option all), option all « Fournisseurs » en « Tous les fournisseurs », « Qty: » en « Qté : », « rév... » en « réf... ». Glossaire global (titres, Gencode, libellés de contact, Prix promo) : à valider avec le métier.
|
||||
|
||||
### CMDCLI-28
|
||||
|
||||
**Couleurs et listes de statuts dupliquées et divergentes (3 copies)** — coherence-design, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/CustomerOrderDetails.tsx:13`
|
||||
- **Constat :** getStatusColor dupliqué : CustomerOrders.tsx:208-223 (bg-*-50 / text-*-700), CustomerOrderDetails.tsx:13-28 (idem), mobile 276-284 (bg-*-100 / text-*-800, statut inconnu = jaune au lieu de gris) ; liste des statuts dupliquée CustomerOrders.tsx:233-239 et CustomerOrderForm.tsx:57-63 (STATUS_OPTIONS jamais utilisé) ; fiche imprimée 420-427 badge toujours jaune quel que soit le statut.
|
||||
- **Impact :** Un même statut n'a pas la même couleur selon l'écran ; toute évolution doit être faite à 3 endroits.
|
||||
- **Recommandation :** Créer `client/src/lib/customerOrderStatus.ts` exportant `CUSTOMER_ORDER_STATUSES`, `statusClass(status)` et un composant `<CustomerOrderStatusBadge>` réutilisé partout (y compris l'impression).
|
||||
|
||||
### CMDCLI-34
|
||||
|
||||
**Mobile : queryFn manuels sans contrôle d'erreur au lieu du fetcher par défaut** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:105`
|
||||
- **Constat :** mobile 93-101 fetch manuel de la liste ; 105-111 `queryFn: async () => { const res = await fetch('/api/suppliers', ...); return res.json(); }` et 113-119 idem pour /api/groups, sans `res.ok` ; ces clés sont identiques à celles servies par le fetcher par défaut (queryClient.ts). /api/suppliers chargé même si la feuille de création n'est jamais ouverte. Desktop : CustomerOrders.tsx:76-85 fetch manuel avec console.log pour pending-calls.
|
||||
- **Impact :** Une réponse d'erreur ({message}) est mise en cache comme liste → `suppliers.map is not a function` à l'ouverture du formulaire ; pas de redirection 401 homogène.
|
||||
- **Recommandation :** Auto : supprimer seulement les queryFn manuels de /api/suppliers et /api/groups (le fetcher par défaut renvoie le même JSON, avec gestion d'erreur) ; `enabled: isCreateOpen` facultatif pour suppliers. Ne pas toucher à la clé ni au queryFn de la liste tant que les clés ne sont pas unifiées (CMDCLI-15/02).
|
||||
|
||||
### CMDCLI-35
|
||||
|
||||
**Dates et montants mal formatés** — lisibilite, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:328`
|
||||
- **Constat :** mobile 328 `format(new Date(order.createdAt), "dd/MM", ...)` sans année ni safeFormat ; 644 `format(new Date(contactOrder.notifiedAt), "dd/MM/yyyy 'à' HH:mm")` sans locale ; 382 `Acompte: {parseFloat(order.deposit)}€` (« 12.5€ ») ; desktop CustomerOrderDetails.tsx:202 et CustomerOrders.tsx:600 `toFixed(2)}€` (« 12.50€ », point décimal).
|
||||
- **Impact :** Commande de l'an dernier indiscernable ; montants au format anglo-saxon.
|
||||
- **Recommandation :** Auto : `new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(parseFloat(order.deposit))` aux 3 endroits (mobile 382, Details 202, impression 600), et safeFormat (dateUtils.ts:8, locale fr par défaut) à la place de format(new Date(...)) en mobile 328/644. Ajouter l'année est optionnel (dd/MM/yy).
|
||||
|
||||
### CMDCLI-36
|
||||
|
||||
**Mobile : feuille « contact client » faite main (pas de focus, pas d'Échap, pas de rôle dialog)** — accessibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/CustomerOrdersPage.tsx:628`
|
||||
- **Constat :** mobile 628-630 `<div className="fixed inset-0 z-50 ..." onClick=...><div className="w-full max-w-lg bg-white rounded-t-2xl ...">` alors que la page utilise déjà Sheet (395) ; boutons natifs <button> stylés à la main (664-682).
|
||||
- **Impact :** Comportement différent des autres feuilles (pas de bouton retour/Échap, focus non piégé, lecteurs d'écran perdus), style incohérent.
|
||||
- **Recommandation :** Remplacer par `<Sheet side="bottom">` + composants Button/Textarea de shadcn.
|
||||
|
||||
### CMDCLI-37
|
||||
|
||||
**Deux parcours différents pour « client contacté » (avec et sans commentaire) et double défilement** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/modals/ClientCallsModal.tsx:25`
|
||||
- **Constat :** ClientCallsModal.tsx:25 `apiRequest(`/api/customer-orders/${customerOrderId}/mark-called`, 'PATCH')` sans commentaire, bouton « Marquer Appelé » (142) ; la modale par ligne (CustomerOrders.tsx:1074-1136) demande un commentaire ; 67 `max-h-[80vh] overflow-y-auto` + 87 `<ScrollArea className="max-h-[50vh]">` imbriqués ; pas de date de mise à disposition ni de lien tel:.
|
||||
- **Impact :** L'utilisateur ne sait pas lequel utiliser ; impossible de noter « message laissé » depuis la liste dédiée ; deux barres de défilement.
|
||||
- **Recommandation :** Réutiliser la même mini-modale contact (commentaire optionnel) depuis ClientCallsModal, afficher « Disponible depuis le … » et le téléphone en lien tel:, garder un seul conteneur scrollable.
|
||||
|
||||
### CMDCLI-38
|
||||
|
||||
**Mise en page chargée et tri incohérent (Retiré en fin de liste mais pas Annulé, tri non modifiable)** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:680`
|
||||
- **Constat :** CustomerOrders.tsx:680-681 seules les commandes « Retiré » sont renvoyées en fin de liste alors que 225-227 grise aussi « Annulé » ; 63-64 `sortBy`/`sortOrder` sans aucun contrôle UI (setSortBy/setSortOrder jamais appelés) ; 777-780 carte « Recherche et Filtres » avec titre ; 740-752 bandeau collant au-dessus du titre de page avec emoji 📞 en plus de l'icône Phone ; pas de bouton « Effacer les filtres ».
|
||||
- **Impact :** Commandes annulées intercalées entre les commandes actives ; espace vertical perdu avant d'atteindre le tableau.
|
||||
- **Recommandation :** Traiter Annulé comme Retiré dans le tri (ou filtre par défaut « En cours »), supprimer l'état de tri mort ou ajouter des en-têtes triables, barre de filtres compacte sans titre avec bouton « Effacer », bandeau d'appels placé sous le titre.
|
||||
|
||||
### CMDCLI-42
|
||||
|
||||
**JsBarcode et la page importés en statique alors qu'ils ne servent qu'à l'impression** — perf-bundle, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:33`
|
||||
- **Constat :** CustomerOrders.tsx:33 `import JsBarcode from 'jsbarcode';` utilisé uniquement dans generateEAN13Barcode (324) ; RouterProduction.tsx:19 et :41 importent CustomerOrders et MobileCustomerOrdersPage sans React.lazy (toutes les pages sont importées en statique).
|
||||
- **Impact :** JsBarcode et le code des commandes clients sont téléchargés au premier chargement de l'application, même pour un utilisateur qui n'ouvre jamais cette page.
|
||||
- **Recommandation :** Ouvrir la fenêtre de façon synchrone (`const w = window.open('', '_blank')`), puis `const { default: JsBarcode } = await import('jsbarcode')`, puis écrire le document. Le lazy-loading des pages doit se faire globalement dans RouterProduction (toutes les pages), avec un fallback Suspense, plutôt que pour une seule page.
|
||||
|
||||
### CMDCLI-43
|
||||
|
||||
**Code mort et contournements dans la page et le formulaire** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/CustomerOrders.tsx:69`
|
||||
- **Constat :** CustomerOrders.tsx:69-71 requête /api/groups jamais utilisée ; 922-925/1095-1100 `(order as any).notifiedAt` alors que le type CustomerOrder contient déjà notifiedAt ; CustomerOrderForm.tsx:154-158 `form.setValue('groupId', …)` appelé pendant le rendu ; :129 `alert("ERREUR: …")` natif ; :134 `user?.firstName + ' ' + user?.lastName || …` (toujours vrai, fallback username inatteignable) ; `useRef` et insertCustomerOrderFrontendSchema importés sans usage (6, 27).
|
||||
- **Impact :** Lecture et maintenance plus difficiles, re-rendus inutiles, message d'erreur non stylé (popup navigateur).
|
||||
- **Recommandation :** Supprimer la requête groups, les `as any`, les imports inutilisés ; déplacer le setValue dans un useEffect ; remplacer alert() par un toast destructive.
|
||||
@@ -0,0 +1,596 @@
|
||||
# DLC
|
||||
|
||||
_51 constats vérifiés — 12 haute, 21 moyenne, 18 basse._
|
||||
|
||||
## Pages analysées
|
||||
|
||||
### `/dlc (desktop, client/src/pages/DlcPage.tsx via RouterProduction.tsx:164)`
|
||||
|
||||
**Rôle :** Suivre les produits à date courte (DLC/DDM/DLUO) du magasin : saisir un produit et sa date, voir ceux qui expirent bientôt ou sont expirés, et les traiter (retrait du rayon, rupture de stock, suppression), imprimer les listes.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Ajouter un produit DLC (nom, fournisseur, EAN avec recherche automatique, date, type de date)
|
||||
- Repérer les produits expirés / expirant dans 15 jours (cartes compteurs + filtre Statut)
|
||||
- Traiter un produit : Valider (retrait), Marquer traité, Stock épuisé, Restaurer, Annuler traitement
|
||||
- Modifier / supprimer un produit
|
||||
- Imprimer la liste des produits expirés ou expirant bientôt
|
||||
- Rechercher / filtrer par statut et fournisseur
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/groups | au montage (enabled: !authLoading), cache partagé avec Layout | server/routes.ts:970 -> storage.getUserWithGroups (storage.ts:371) + storage.getGroups (storage.ts:432) pour admin | Sert uniquement au fallback groupId de onSubmit ; doublon de useStore().stores déjà chargé par Layout.tsx:48. |
|
||||
| GET /api/suppliers?dlc=true | au montage, staleTime 10 min | server/routes.ts:1141 -> storage.getSuppliers (storage.ts:467) puis filtre JS hasDlc | Charge tous les fournisseurs puis filtre en JS au lieu d'un WHERE has_dlc. |
|
||||
| GET /api/dlc-products?storeId&status&supplierId&search | au montage + à chaque changement de filtre / recherche (debounce 500 ms), staleTime 2 min | server/routes.ts:2797 -> storage.getUserWithGroups (2 requêtes) + storage.getDlcProducts (storage.ts:1976) | Aucune limite/pagination serveur ; SELECT complet de suppliers et groups (dont groups.logo en data URI) pour chaque ligne ; storeId envoyé seulement pour admin. |
|
||||
| GET /api/dlc-products/stats?storeId | au montage, staleTime 5 min | server/routes.ts:2831 -> storage.getUserWithGroups + storage.getDlcStats (storage.ts:2224) | Même queryKey que Dashboard.tsx:232 mais URL différente (storeId admin uniquement ici) ; compteurs renvoyés en chaînes (COUNT bigint). |
|
||||
| POST /api/dlc-products | au clic Créer | server/routes.ts:2889 -> storage.createDlcProduct (storage.ts:2110) | req.body inséré tel quel, sans validation zod ni liste blanche de champs. |
|
||||
| PUT /api/dlc-products/:id | au clic Mettre à jour | server/routes.ts:2917 -> storage.getDlcProduct (storage.ts:2088) + storage.updateDlcProduct (storage.ts:2115) | req.body passé tel quel à .set() : groupId/status/validatedBy modifiables. |
|
||||
| DELETE /api/dlc-products/:id | au clic Supprimer (après AlertDialog) | server/routes.ts:2947 -> storage.getDlcProduct + storage.deleteDlcProduct (storage.ts:2124) | Aucun contrôle de rôle : tout employé peut supprimer. |
|
||||
| POST /api/dlc-products/:id/validate | au clic bouton vert (admin/manager/directeur) | server/routes.ts:2977 -> storage.getDlcProduct + storage.validateDlcProduct (storage.ts:2128) | 2 console.log par appel ; pas de confirmation côté client pour une action définitive. |
|
||||
| PUT /api/dlc-products/:id/stock-epuise | au clic PackageX | server/routes.ts:3023 -> storage.getDlcProduct + storage.markDlcProductStockEpuise (storage.ts:2142) | Invalide liste + stats (2 refetchs complets). |
|
||||
| PUT /api/dlc-products/:id/restore-stock | au clic RotateCcw (admin/manager/directeur) | server/routes.ts:3063 -> storage.getDlcProduct + storage.restoreDlcProductStock (storage.ts:2156) | Idem. |
|
||||
| PUT /api/dlc-products/:id/mark-processed | au clic CheckCircle2 | server/routes.ts:3108 -> storage.getDlcProduct + storage.markDlcProductAsProcessed (storage.ts:2170) | Le produit traité est ensuite exclu définitivement des filtres/compteurs « Expirés ». |
|
||||
| PUT /api/dlc-products/:id/unmark-processed | au clic X (admin/manager/directeur) | server/routes.ts:3148 -> storage.getDlcProduct + storage.unmarkDlcProductAsProcessed (storage.ts:2197) | Idem. |
|
||||
| GET /api/ffnancy/articles?ean=…&limit=1 puis GET /api/ffnancy/mouvements/entrees?artNoId=… | saisie du champ EAN (≥ 8 caractères, debounce 600 ms) — aussi déclenché à l'ouverture en édition | server/routes.ts:6396 et 6411 -> proxy fetch https://api.ffnancy.fr sans timeout ni cache | 2 allers-retours séquentiels client→serveur→API externe, fetch() manuels sans AbortController. |
|
||||
|
||||
**Lisibilité / simplicité :** Page fonctionnelle mais chargée et ambiguë pour un employé : filtres placés avant les compteurs, compteurs non cliquables et incohérents avec les listes filtrées (« Actifs » inclut les « Expire bientôt », le jour J compté deux fois), jusqu'à 5 boutons icônes colorés par ligne (Modifier/Supprimer sans infobulle, deux icônes de coche quasi identiques pour « Valider » et « Traité »), 4 notions qui se recouvrent (Validé, Traité, Stock épuisé, Supprimé). Bouton « Exporter PDF » inactif, mini-icônes d'impression de 24 px sans libellé. Le jour J, aucun bouton Valider/Traiter n'apparaît. Le chargement remplace le tableau par un texte à chaque frappe ; les erreurs serveur s'affichent en JSON anglais ou se déguisent en « Aucun produit ». Bug de dialogue : après « Annuler » d'une modification, « Nouveau produit DLC » rouvre en mode modification. console.log de debug dans onSubmit.
|
||||
|
||||
### `/dlc (mobile, client/src/pages/mobile/DlcPage.tsx via RouterProduction.tsx:124)`
|
||||
|
||||
**Rôle :** Version téléphone de la gestion DLC : consulter la liste par onglet (OK / Bientôt / Expirés / Validés), ajouter un produit (scan EAN) et valider un produit expiré.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Voir les produits par onglet d'urgence
|
||||
- Rechercher un produit (nom ou EAN)
|
||||
- Ajouter un produit via le bouton flottant + feuille de saisie
|
||||
- Valider (sortir) un produit via le menu …
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/dlc-products?storeId=… | au montage puis à CHAQUE changement d'onglet et CHAQUE frappe dans la recherche (queryKey contient statusFilter et searchTerm), seulement si selectedStoreId | server/routes.ts:2797 -> storage.getDlcProducts (storage.ts:1976) | fetch() manuel sans contrôle res.ok ; le queryFn ignore statut et recherche : même réponse complète re-téléchargée à chaque fois. |
|
||||
| GET /api/suppliers?dlc=true | au montage (pas d'enabled) | server/routes.ts:1141 -> storage.getSuppliers (storage.ts:467) | Même clé que desktop, OK. |
|
||||
| GET /api/groups | au montage | server/routes.ts:970 -> storage.getUserWithGroups / getGroups | Redondant avec useStore().stores fourni par MobileApp.tsx:28. |
|
||||
| POST /api/dlc-products | soumission du formulaire | server/routes.ts:2889 -> storage.createDlcProduct (storage.ts:2110) | supplierId par défaut = 1. |
|
||||
| POST /api/dlc-products/:id/validate | menu … > Valider (Sortir) | server/routes.ts:2977 -> storage.validateDlcProduct (storage.ts:2128) | Proposé à tous les rôles, refusé (403) pour les employés, sans onError. |
|
||||
| GET /api/ffnancy/articles + /api/ffnancy/mouvements/entrees | saisie EAN ≥ 8 caractères (debounce 600 ms) | server/routes.ts:6396 / 6411 (proxy externe) | Code copié-collé du desktop. |
|
||||
|
||||
**Lisibilité / simplicité :** Interface simple (cartes, onglets, bouton flottant) mais : liste vide pour les employés et pour « Tous les magasins » (requête désactivée sans magasin sélectionné, sans message explicatif), onglet par défaut « OK » (le moins urgent), onglets sans compteurs, badge « J-3 » identique pour « expire dans 3 jours » et « expiré depuis 3 jours », formulaire sans aucun message d'erreur, fournisseur n°1 pré-sélectionné en silence, date du jour pré-remplie, action « Valider » silencieusement refusée aux employés et sans confirmation. Fonctions absentes par rapport au desktop (modifier, supprimer, stock épuisé, traité). Chaque frappe recharge toute la liste.
|
||||
|
||||
### `/ (Dashboard) — modale DlcAlertModal (client/src/components/DlcAlertModal.tsx, utilisée dans client/src/pages/Dashboard.tsx:773)`
|
||||
|
||||
**Rôle :** Alerter automatiquement les utilisateurs non-admin, à l'ouverture du tableau de bord, des produits expirés ou expirant sous 15 jours, avec action rapide « Stock épuisé » et lien vers la page DLC.
|
||||
|
||||
**Tâches principales de l'utilisateur :**
|
||||
- Prendre connaissance des produits expirés / bientôt expirés
|
||||
- Marquer un produit expiré comme « Stock épuisé »
|
||||
- Aller à la page DLC
|
||||
- Reporter l'alerte de 2 h
|
||||
|
||||
**Appels API :**
|
||||
|
||||
| Endpoint | Déclencheur | Handler serveur | Notes |
|
||||
|---|---|---|---|
|
||||
| GET /api/dlc-products/stats?storeId | au montage du Dashboard (Dashboard.tsx:231) + invalidation au montage (Dashboard.tsx:22-26) | server/routes.ts:2831 -> storage.getDlcStats (storage.ts:2224) | Requête lancée puis relancée par l'invalidation du useEffect au montage. |
|
||||
| GET /api/dlc-products?status=expires&storeId | à l'ouverture de la modale si stats.expired > 0 | server/routes.ts:2797 -> storage.getDlcProducts filtre 'expires' (storage.ts:2017) | fetch() manuel sans res.ok ; liste complète téléchargée pour n'en afficher que 8 ; inclut les stocks épuisés alors que le compteur les exclut. |
|
||||
| GET /api/dlc-products?status=expires_soon&storeId | à l'ouverture si stats.expiringSoon > 0 | server/routes.ts:2797 -> storage.getDlcProducts filtre 'expires_soon' (storage.ts:2000) | Idem. |
|
||||
| PUT /api/dlc-products/:id/stock-epuise | clic « Stock épuisé » | server/routes.ts:3023 -> storage.markDlcProductStockEpuise (storage.ts:2142) | Invalide liste et stats. |
|
||||
|
||||
**Lisibilité / simplicité :** La modale ne peut pas être fermée par la croix/Échap/clic extérieur (elle se rouvre aussitôt) : seul « Traiter plus tard (2h) » fonctionne. Elle pousse à marquer un produit EXPIRÉ comme « stock épuisé » pour faire disparaître l'alerte, ce qui contredit la définition de la page DLC (« différent de périmé »). Aucun bouton d'action pour les produits qui expirent bientôt. Jargon (« Ce modal »), emojis dans les toasts, compteurs d'en-tête différents du nombre de lignes affichées, « Expiré depuis 0 jour(s) » le jour J. Le lien « Voir tous les produits DLC » recharge toute l'application.
|
||||
|
||||
## Constats
|
||||
|
||||
| ID | Sév. | Catégorie | Effort | Titre | Fichier |
|
||||
|---|---|---|---|---|---|
|
||||
| [DLC-01](#dlc-01) | haute | bug | S | Un produit « traité » ne réapparaît jamais une fois expiré (filtre Expirés, compteurs et alerte) | `server/storage.ts:2017` |
|
||||
| [DLC-02](#dlc-02) | haute | bug | S | Le jour J, aucun bouton « Valider » ni « Marquer traité » et badge « Expire bientôt » alors que le serveur le classe « Expiré » | `client/src/pages/DlcPage.tsx:425` |
|
||||
| [DLC-03](#dlc-03) | haute | bug | S | Après « Annuler » une modification, « Nouveau produit DLC » rouvre en mode modification et écrase l'ancien produit | `client/src/pages/DlcPage.tsx:668` |
|
||||
| [DLC-04](#dlc-04) | haute | bug | S | Ouvrir un produit en modification relance la recherche EAN et écrase son nom et son fournisseur | `client/src/pages/DlcPage.tsx:123` |
|
||||
| [DLC-05](#dlc-05) | haute | bug | S | Le magasin du produit ignore le magasin sélectionné et une modification peut déplacer le produit dans un autre magasin | `client/src/pages/DlcPage.tsx:330` |
|
||||
| [DLC-06](#dlc-06) | haute | bug | M | POST/PUT DLC sans validation : un employé peut valider un produit ou le déplacer vers un autre magasin | `server/routes.ts:2940` |
|
||||
| [DLC-07](#dlc-07) | haute | perf-api | S | Chaque produit DLC renvoie la ligne complète du magasin (logo base64, config SMTP/NocoDB) et du fournisseur | `server/storage.ts:1977` |
|
||||
| [DLC-08](#dlc-08) | haute | bug | S | La modale d'alerte DLC se rouvre immédiatement quand on la ferme par la croix, Échap ou clic extérieur | `client/src/pages/Dashboard.tsx:257` |
|
||||
| [DLC-09](#dlc-09) | haute | perf-client | S | Mobile : toute la liste est re-téléchargée à chaque frappe de recherche et à chaque changement d'onglet | `client/src/pages/mobile/DlcPage.tsx:84` |
|
||||
| [DLC-10](#dlc-10) | haute | bug | S | Mobile : liste DLC toujours vide pour les employés et pour « Tous les magasins » | `client/src/pages/mobile/DlcPage.tsx:96` |
|
||||
| [DLC-11](#dlc-11) | haute | bug | S | Mobile : fournisseur n°1 attribué en silence et choix fournisseur non synchronisé | `client/src/pages/mobile/DlcPage.tsx:137` |
|
||||
| [DLC-26](#dlc-26) | haute | bug | S | Injection HTML/JS dans l'impression (document.write avec nom de produit non échappé) | `client/src/pages/DlcPage.tsx:535` |
|
||||
| [DLC-12](#dlc-12) | moyenne | ux-simplicite | S | Mobile : formulaire sans aucun message d'erreur | `client/src/pages/mobile/DlcPage.tsx:368` |
|
||||
| [DLC-13](#dlc-13) | moyenne | bug | S | Mobile : « Valider (Sortir) » proposé à tous, refusé en silence aux employés et sans confirmation | `client/src/pages/mobile/DlcPage.tsx:121` |
|
||||
| [DLC-14](#dlc-14) | moyenne | bug | M | Compteurs incohérents avec les listes : « Actifs » inclut « Expire bientôt », le jour J compté deux fois, stocks épuisés comptés différemment | `server/storage.ts:2238` |
|
||||
| [DLC-15](#dlc-15) | moyenne | bug | S | Desktop : le magasin sélectionné est ignoré pour les non-admins et la clé de cache des stats entre en collision avec le Dashboard | `client/src/pages/DlcPage.tsx:83` |
|
||||
| [DLC-16](#dlc-16) | moyenne | perf-client | S | Le tableau disparaît à chaque changement de filtre ou de recherche (pas de placeholderData ni de squelette) | `client/src/pages/DlcPage.tsx:937` |
|
||||
| [DLC-17](#dlc-17) | moyenne | perf-api | M | Aucune pagination serveur : l'historique complet (y compris tous les produits validés) est chargé par défaut | `server/storage.ts:2063` |
|
||||
| [DLC-18](#dlc-18) | moyenne | perf-serveur | M | Chaque requête DLC recharge l'utilisateur (2 requêtes) et chaque action relit le produit avec 2 jointures | `server/routes.ts:2799` |
|
||||
| [DLC-19](#dlc-19) | moyenne | perf-client | M | Chaque clic d'action recharge toute la liste + les stats et désactive le bouton sur toutes les lignes | `client/src/pages/DlcPage.tsx:255` |
|
||||
| [DLC-20](#dlc-20) | moyenne | perf-api | S | Invalidations au montage : la requête de stats DLC part deux fois à chaque visite du tableau de bord | `client/src/components/DlcAlertModal.tsx:45` |
|
||||
| [DLC-21](#dlc-21) | moyenne | ux-simplicite | S | Messages d'erreur affichés en JSON anglais avec code HTTP | `client/src/lib/queryClient.ts:6` |
|
||||
| [DLC-22](#dlc-22) | moyenne | ux-simplicite | S | Les erreurs serveur sont déguisées en « Aucun produit » ou en compteurs à 0 | `server/routes.ts:2827` |
|
||||
| [DLC-23](#dlc-23) | moyenne | ux-simplicite | M | Jusqu'à 5 boutons icônes colorés par ligne, deux coches quasi identiques et 4 notions qui se recouvrent | `client/src/pages/DlcPage.tsx:982` |
|
||||
| [DLC-25](#dlc-25) | moyenne | ux-simplicite | S | Impression cachée dans des icônes de 24 px et liste imprimée différente du compteur affiché | `client/src/pages/DlcPage.tsx:887` |
|
||||
| [DLC-27](#dlc-27) | moyenne | ux-simplicite | S | Hiérarchie visuelle inversée : filtres avant les compteurs, compteurs non cliquables | `client/src/pages/DlcPage.tsx:817` |
|
||||
| [DLC-28](#dlc-28) | moyenne | coherence-design | M | Vocabulaire, couleurs et seuils d'urgence différents entre desktop, mobile et modale | `client/src/pages/mobile/DlcPage.tsx:293` |
|
||||
| [DLC-29](#dlc-29) | moyenne | lisibilite | S | Badge « J-3 » identique pour « expire dans 3 jours » et « expiré depuis 3 jours » | `client/src/pages/mobile/DlcPage.tsx:264` |
|
||||
| [DLC-30](#dlc-30) | moyenne | ux-simplicite | S | Mobile : l'onglet par défaut montre les produits les moins urgents et les onglets n'ont pas de compteur | `client/src/pages/mobile/DlcPage.tsx:77` |
|
||||
| [DLC-31](#dlc-31) | moyenne | ux-simplicite | M | Mobile : actions manquantes par rapport au desktop (modifier, supprimer, stock épuisé, traité) | `client/src/pages/mobile/DlcPage.tsx:336` |
|
||||
| [DLC-32](#dlc-32) | moyenne | ux-simplicite | S | La modale incite à marquer un produit périmé « stock épuisé » pour faire disparaître l'alerte | `client/src/components/DlcAlertModal.tsx:285` |
|
||||
| [DLC-36](#dlc-36) | moyenne | perf-api | M | Recherche EAN : 2 allers-retours séquentiels vers une API externe, sans cache, sans délai max, code dupliqué | `client/src/pages/DlcPage.tsx:132` |
|
||||
| [DLC-47](#dlc-47) | moyenne | ux-simplicite | S | La recherche desktop ne trouve pas un produit par son code EAN (le mobile oui) | `server/storage.ts:2049` |
|
||||
| [DLC-24](#dlc-24) | basse | ux-simplicite | S | Bouton « Exporter PDF » sans action | `client/src/pages/DlcPage.tsx:930` |
|
||||
| [DLC-33](#dlc-33) | basse | perf-client | S | Le lien vers la page DLC recharge toute l'application | `client/src/components/DlcAlertModal.tsx:142` |
|
||||
| [DLC-34](#dlc-34) | basse | lisibilite | S | Jargon, emojis et formulations maladroites dans la modale | `client/src/components/DlcAlertModal.tsx:285` |
|
||||
| [DLC-35](#dlc-35) | basse | dette-code | S | console.log de debug dans onSubmit et dans les routes de mutation | `client/src/pages/DlcPage.tsx:348` |
|
||||
| [DLC-37](#dlc-37) | basse | perf-serveur | S | Fournisseurs DLC filtrés en JavaScript après lecture de toute la table | `server/routes.ts:1150` |
|
||||
| [DLC-38](#dlc-38) | basse | dette-code | S | Les compteurs DLC sont renvoyés en chaînes (COUNT bigint), comparaison stricte morte dans la modale | `server/storage.ts:2261` |
|
||||
| [DLC-39](#dlc-39) | basse | perf-serveur | S | Les stats DLC parcourent tout l'historique (validés inclus) et utilisent la date UTC | `server/storage.ts:2229` |
|
||||
| [DLC-40](#dlc-40) | basse | dette-code | S | Index dlc_products absents de shared/schema.ts (risque de suppression par drizzle-kit push) | `shared/schema.ts:278` |
|
||||
| [DLC-41](#dlc-41) | basse | accessibilite | S | Boutons icônes sans libellé accessible (Modifier, Supprimer, Imprimer, menu …, bouton +) | `client/src/pages/DlcPage.tsx:983` |
|
||||
| [DLC-42](#dlc-42) | basse | accessibilite | S | Lignes grisées par opacité 50-60 % : texte et boutons peu lisibles | `client/src/pages/DlcPage.tsx:962` |
|
||||
| [DLC-43](#dlc-43) | basse | ux-simplicite | S | Tableau DLC non défilable et sans colonne Magasin sur tablette / vue multi-magasins | `client/src/components/ui/table.tsx:9` |
|
||||
| [DLC-44](#dlc-44) | basse | lisibilite | S | Message de validation « Required » en anglais si aucun fournisseur choisi | `client/src/pages/DlcPage.tsx:33` |
|
||||
| [DLC-45](#dlc-45) | basse | dette-code | M | Code dupliqué : 7 mutations copiées-collées, 2 fonctions d'impression identiques, TooltipProvider par bouton, imports morts | `client/src/pages/DlcPage.tsx:178` |
|
||||
| [DLC-46](#dlc-46) | basse | perf-bundle | S | Pages DLC desktop et mobile importées statiquement dans le bundle principal | `client/src/components/RouterProduction.tsx:21` |
|
||||
| [DLC-48](#dlc-48) | basse | bug | S | Recherche mobile plantée si un produit n'a pas de productName (colonne nullable) | `client/src/pages/mobile/DlcPage.tsx:236` |
|
||||
| [DLC-49](#dlc-49) | basse | ux-simplicite | S | Mobile : date d'expiration pré-remplie à aujourd'hui | `client/src/pages/mobile/DlcPage.tsx:135` |
|
||||
| [DLC-50](#dlc-50) | basse | lisibilite | S | Choix DLC / DDM / DLUO : DLUO est un terme obsolète qui ajoute de la confusion | `client/src/pages/DlcPage.tsx:777` |
|
||||
| [DLC-51](#dlc-51) | basse | ux-simplicite | S | Retour à la page 1 après chaque action qui retire une ligne de la vue filtrée | `client/src/components/ui/pagination.tsx:157` |
|
||||
|
||||
### DLC-01
|
||||
|
||||
**Un produit « traité » ne réapparaît jamais une fois expiré (filtre Expirés, compteurs et alerte)** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2017`
|
||||
- **Constat :** storage.ts:2017-2027 filtre 'expires' : and(lte(expiryDate, today), ne(status,'valides'), or(isNull(processedUntilExpiry), eq(processedUntilExpiry,false))) ; storage.ts:2250-2254 compteur expired : AND (processedUntilExpiry IS NULL OR = false). Aucun job ne remet processedUntilExpiry à false (grep sans résultat hors storage.ts). Or DlcPage.tsx:1023 promet : « Indique que vous vous êtes occupé de ce produit (réapparaîtra à expiration) ».
|
||||
- **Impact :** Sécurité alimentaire : un produit marqué « traité » quand il expirait bientôt disparaît définitivement du filtre « Expirés », du compteur rouge et de la modale d'alerte du tableau de bord ; il ne reste visible qu'en vue « Tous les statuts », grisé et trié en fin de liste.
|
||||
- **Recommandation :** Comme proposé : retirer l'exclusion processedUntilExpiry du filtre 'expires' et du COUNT expired, en la gardant pour 'expires_soon'. Il faut aussi adapter le ORDER BY (storage.ts:2066-2074 : un produit traité expiré ne doit plus être classé en priorité 2) et le grisage de la ligne (DlcPage.tsx:963). Aligner MemStorage (storage.ts:4538-4549). À coordonner avec DLC-39 : si l'exclusion passe dans le WHERE des stats, elle ne doit pas s'appliquer aux expirés.
|
||||
|
||||
### DLC-02
|
||||
|
||||
**Le jour J, aucun bouton « Valider » ni « Marquer traité » et badge « Expire bientôt » alors que le serveur le classe « Expiré »** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:425`
|
||||
- **Constat :** daysUntilExpiry = Math.ceil((expiry - today)/86400000) avec expiry = new Date('YYYY-MM-DD') (minuit UTC, drizzle renvoie la date en chaîne : node_modules/drizzle-orm/node-postgres/session.js:31). Le jour J la valeur vaut -0 → shouldShowValidateButton exige `daysUntilExpiry < 0` (l.432) et shouldShowMarkProcessedButton exige `daysUntilExpiry > 0` (l.445) : aucun des deux. getStatusBadge l.490-493 affiche « Expire bientôt ». Côté serveur, le filtre 'expires' utilise lte(expiryDate, today) (storage.ts:2021) : le produit du jour est listé dans « Expirés ».
|
||||
- **Impact :** Le jour le plus critique, l'employé voit le produit dans « Expirés » avec un badge orange « Expire bientôt » et sans action de retrait possible : confusion et retrait oublié.
|
||||
- **Recommandation :** Créer un utilitaire partagé (ex. shared/dlc.ts) : getDaysUntilExpiry(date) = differenceInCalendarDays(parseISO(date), startOfToday()) et des seuils uniques (≤ 0 = expiré, 1-15 = bientôt). L'utiliser dans DlcPage, mobile/DlcPage, DlcAlertModal et aligner la requête SQL (expiry_date <= CURRENT_DATE).
|
||||
|
||||
### DLC-03
|
||||
|
||||
**Après « Annuler » une modification, « Nouveau produit DLC » rouvre en mode modification et écrase l'ancien produit** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:668`
|
||||
- **Constat :** handleEdit (l.377-389) fait setEditingProduct(product) + form.reset(...). Le bouton Annuler (l.803) fait seulement `setIsDialogOpen(false)` et `<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>` (l.668) ne remet ni editingProduct ni le formulaire à zéro. Le DialogTrigger « Nouveau produit DLC » (l.669-673) rouvre donc avec le titre « Modifier le produit DLC » et onSubmit appelle updateMutation (l.370-371).
|
||||
- **Impact :** Un employé qui croit créer un nouveau produit modifie en réalité le dernier produit ouvert : perte de données silencieuse.
|
||||
- **Recommandation :** Créer une fonction closeDialog() qui fait setIsDialogOpen(false), setEditingProduct(null) et form.reset(). L'utiliser dans onOpenChange (si !open), dans le bouton Annuler (un appel programmatique ne déclenche pas onOpenChange) et dans les onSuccess. Au clic sur « Nouveau produit DLC », faire setEditingProduct(null) et form.reset().
|
||||
|
||||
### DLC-04
|
||||
|
||||
**Ouvrir un produit en modification relance la recherche EAN et écrase son nom et son fournisseur** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:123`
|
||||
- **Constat :** useEffect([gencodeValue, suppliers]) l.123-176 se déclenche dès que gencode ≥ 8 caractères. handleEdit appelle form.reset({ gencode: product.gencode, ... }) (l.379-381) → 600 ms plus tard `form.setValue("productName", article.libelle1)` (l.138) et `form.setValue("supplierId", matched.id)` (l.163). Aucun AbortController : une réponse ancienne peut aussi écraser une saisie plus récente.
|
||||
- **Impact :** Le nom personnalisé (« Yaourt fraise lot promo ») est remplacé sans prévenir par le libellé ffnancy ; le fournisseur peut changer. L'utilisateur enregistre sans s'en rendre compte.
|
||||
- **Recommandation :** Garder le remplissage automatique en création, puisque c'est la fonctionnalité voulue ; remplacer l'écrasement par un simple message serait une décision produit. Correctif sûr : ne pas lancer la recherche en mode modification tant que le gencode est celui du produit (editingProduct?.gencode === gencodeValue), ou ne la déclencher que depuis l'onChange du champ. Ajouter un AbortController annulé dans le cleanup, et ignorer une réponse dont le gencode ne correspond plus à la valeur courante.
|
||||
|
||||
### DLC-05
|
||||
|
||||
**Le magasin du produit ignore le magasin sélectionné et une modification peut déplacer le produit dans un autre magasin** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:330`
|
||||
- **Constat :** onSubmit l.329-346 : « FORCE L'UTILISATION DU GROUPE ASSIGNÉ EN PREMIER » `if (user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;` puis fallback `groupId = 1` (l.344). dlcData contient groupId et est aussi envoyé en modification (l.371). Le mobile fait l'inverse : `let groupId = selectedStoreId;` (mobile/DlcPage.tsx:204).
|
||||
- **Impact :** Un manager/directeur multi-magasins qui a sélectionné le magasin B crée ses produits dans le magasin A ; modifier un produit du magasin B le transfère dans A. Les produits « disparaissent » du magasin attendu.
|
||||
- **Recommandation :** En création : groupId = selectedStoreId s'il est défini, sinon unique magasin de l'utilisateur, sinon afficher un champ « Magasin » obligatoire (supprimer le fallback 1). En modification : ne pas envoyer groupId.
|
||||
|
||||
### DLC-06
|
||||
|
||||
**POST/PUT DLC sans validation : un employé peut valider un produit ou le déplacer vers un autre magasin** — bug, sévérité haute, effort M
|
||||
|
||||
- **Fichier :** `server/routes.ts:2940`
|
||||
- **Constat :** routes.ts:2896-2909 `const data = { ...req.body, createdBy: user.id }; ... storage.createDlcProduct(data)` ; routes.ts:2940 `storage.updateDlcProduct(id, req.body)` puis storage.ts:2118 `.set({ ...productData, updatedAt })`. Aucun insertDlcProductSchema.parse ; le nouveau groupId n'est pas vérifié. Le rôle requis pour /validate (routes.ts:2985 ['admin','directeur','manager']) est contournable via PUT {status:'valides'}. DELETE (routes.ts:2947) n'a aucun contrôle de rôle.
|
||||
- **Impact :** Contournement des droits (validation, transfert vers un magasin non autorisé, modification de validatedBy/stockEpuiseBy) ; payload invalide → 500 générique « Failed to create DLC product » au lieu d'erreurs de champ.
|
||||
- **Recommandation :** Schéma zod d'édition : productName, name (le client l'envoie pour garder la colonne NOT NULL synchronisée, DlcPage.tsx:361), gencode, expiryDate (accepter la chaîne ISO datetime envoyée par le client : new Date(...) sérialisé), dateType, supplierId, notes. Ignorer status, groupId et les champs validated*/processed*/stockEpuise* en PUT ; en POST, vérifier que groupId appartient aux groupes de l'utilisateur (déjà fait, routes.ts:2902-2906) et forcer status='en_cours'. Le contrôle de rôle sur DELETE est une décision produit séparée.
|
||||
|
||||
### DLC-07
|
||||
|
||||
**Chaque produit DLC renvoie la ligne complète du magasin (logo base64, config SMTP/NocoDB) et du fournisseur** — perf-api, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:1977`
|
||||
- **Constat :** storage.ts:1977-1985 `.select({ dlcProduct: dlcProducts, supplier: suppliers, group: groups })` ; idem getDlcProduct storage.ts:2089-2098. shared/schema.ts:67 `logo: text("logo"), // Logo en data URI (data:image/png;base64,...)` + smtpHost/smtpUser/webhookUrl/nocodb*. Le client n'utilise que product.supplier?.name (DlcPage.tsx:979, mobile l.325, DlcAlertModal l.178) et jamais product.group. Pas de compression gzip dans server/index.production.ts (aucun middleware compression). stripSmtpPassword (server/sanitize.ts) reparcourt récursivement tout ce JSON.
|
||||
- **Impact :** Avec un logo de 50 Ko et 300 produits, la réponse dépasse 15 Mo non compressés : chargement de la page DLC et de la modale de plusieurs secondes sur le Wi-Fi/4G du magasin, parsing JSON lourd sur mobile, fuite d'informations de configuration à tous les employés.
|
||||
- **Recommandation :** Partie automatisable : dans getDlcProducts, sélectionner `supplier: { id: suppliers.id, name: suppliers.name }` et `group: { id: groups.id, name: groups.name, color: groups.color }`. Dans getDlcProduct (utilisé pour les contrôles de droits et par GET /api/dlc-products/:id, qu'aucun client n'appelle), même restriction. Activer la compression demande d'ajouter une dépendance (`compression`) dans index.production.ts et index.ts : c'est un changement séparé, non automatique. Ne surtout pas brancher setupCompression de cache.ts.
|
||||
|
||||
### DLC-08
|
||||
|
||||
**La modale d'alerte DLC se rouvre immédiatement quand on la ferme par la croix, Échap ou clic extérieur** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/Dashboard.tsx:257`
|
||||
- **Constat :** DlcAlertModal.tsx:146 `<Dialog open={isOpen} onOpenChange={onClose}>` → handleCloseDlcAlertModal met showDlcAlertModal à false (Dashboard.tsx:276-278). L'effet Dashboard.tsx:243-273 a showDlcAlertModal dans ses dépendances : `if (shouldShowDlcModal && !showDlcAlertModal) { ... } else { setShowDlcAlertModal(true); }` (l.257-268) sans snooze → réouverture instantanée. Seul handleSnooze (DlcAlertModal.tsx:106-112) écrit dlcAlertSnooze.
|
||||
- **Impact :** Pour un employé, la croix « ne marche pas » : il est bloqué sur une fenêtre rouge tant qu'il ne trouve pas « Traiter plus tard (2h) ». Expérience très frustrante à chaque ouverture du tableau de bord.
|
||||
- **Recommandation :** Considérer toute fermeture comme une mise en sourdine (au minimum pour la session : sessionStorage ou useRef « dismissed »), et retirer showDlcAlertModal des dépendances de l'effet ; n'ouvrir automatiquement qu'une fois par chargement.
|
||||
|
||||
### DLC-09
|
||||
|
||||
**Mobile : toute la liste est re-téléchargée à chaque frappe de recherche et à chaque changement d'onglet** — perf-client, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:84`
|
||||
- **Constat :** `queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm]` (l.84) alors que le queryFn ignore statut et recherche : `fetch(`/api/dlc-products?storeId=${selectedStoreId}`)` (l.91). Le filtrage est déjà fait en useMemo (l.227-257). Pas de debounce sur searchTerm (l.285).
|
||||
- **Impact :** Taper « yaourt » = 6 requêtes complètes identiques (avec la charge de DLC-07) ; chaque onglet affiche un spinner alors que les données sont déjà en mémoire. Très lent en 4G en rayon.
|
||||
- **Recommandation :** queryKey ["/api/dlc-products", "mobile", selectedStoreId] (le préfixe reste invalidé par les mutations), staleTime 2 min, filtrage uniquement côté client avec useDeferredValue(searchTerm), queryFn via apiRequest.
|
||||
|
||||
### DLC-10
|
||||
|
||||
**Mobile : liste DLC toujours vide pour les employés et pour « Tous les magasins »** — bug, sévérité haute, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:96`
|
||||
- **Constat :** `enabled: !!selectedStoreId && !!user` (l.96) et `if (selectedStoreId) {...} return [];` (l.90-94). MobileApp.tsx:33-38 ne pré-sélectionne un magasin que pour `user.role === 'directeur' || user.role === 'manager'` avec un seul magasin. Le serveur sait pourtant limiter aux magasins de l'utilisateur sans storeId (routes.ts:2809-2815). L'état vide affiche « Aucun produit trouvé » (l.306-310).
|
||||
- **Impact :** Un employé ouvre « DLC » sur son téléphone et voit « Aucun produit trouvé » alors que des produits expirent : faux sentiment de sécurité.
|
||||
- **Recommandation :** Appeler /api/dlc-products sans storeId quand selectedStoreId est null (routes.ts:2807-2815 restreint déjà aux magasins de l'utilisateur), en retirant la condition selectedStoreId de enabled ; sinon, afficher un état vide explicite « Choisissez votre magasin » avec un bouton qui ouvre le menu.
|
||||
|
||||
### DLC-11
|
||||
|
||||
**Mobile : fournisseur n°1 attribué en silence et choix fournisseur non synchronisé** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:137`
|
||||
- **Constat :** defaultValues `supplierId: 1` (l.137) ; Select non contrôlé `defaultValue={field.value.toString()}` (l.424) et dateType `defaultValue={field.value}` (l.398). Si le fournisseur 1 n'est pas « DLC », le Select affiche « Choisir... » mais la valeur 1 passe la validation `z.coerce.number().min(1)` (l.68). Le form.setValue("supplierId", matched.id) de la recherche EAN (l.188) ne met pas à jour l'affichage.
|
||||
- **Impact :** Des produits sont enregistrés avec un mauvais fournisseur sans que l'utilisateur le voie ; filtrage par fournisseur et retours fournisseurs faussés.
|
||||
- **Recommandation :** supplierId: undefined par défaut et Select contrôlé `value={field.value ? String(field.value) : ""}` ("" pour revenir au placeholder après reset), idem pour dateType. Avec z.coerce, undefined devient NaN et produit « Expected number, received nan » : utiliser `z.coerce.number({ invalid_type_error: "Fournisseur requis" }).min(1, "Fournisseur requis")`. À livrer avec DLC-12, sinon la soumission échoue sans aucun message.
|
||||
|
||||
### DLC-26
|
||||
|
||||
**Injection HTML/JS dans l'impression (document.write avec nom de produit non échappé)** — bug, sévérité haute, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:535`
|
||||
- **Constat :** l.529-543 : `<td>${product.productName}</td> <td>${product.gencode || '-'}</td> ... ${product.supplier?.name}` puis l.550-554 `window.open('', '_blank')` + `printWindow.document.write(printContent)` (fenêtre about:blank de même origine). En production Docker (Dockerfile:41 compile server/index.production.ts) aucun middleware setupInputSanitization n'est appliqué (présent seulement dans server/index.ts:37).
|
||||
- **Impact :** Un nom de produit contenant `<img src=x onerror=...>` saisi par n'importe quel employé s'exécute dans la session du manager qui imprime (XSS stocké).
|
||||
- **Recommandation :** Échapper toutes les valeurs (helper escapeHtml) ou construire le tableau via DOM/textContent ; factoriser printExpired/printExpiringSoon en une fonction printList(titre, produits).
|
||||
|
||||
### DLC-12
|
||||
|
||||
**Mobile : formulaire sans aucun message d'erreur** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:368`
|
||||
- **Constat :** Aucun <FormMessage /> dans les FormItem (l.364-476), alors que le schéma définit « Nom du produit requis », « Date requise », « Fournisseur requis » (l.64-68).
|
||||
- **Impact :** Si un champ manque, le bouton « Ajouter le produit » ne fait rien de visible : l'utilisateur pense que l'application est bloquée.
|
||||
- **Recommandation :** Ajouter <FormMessage /> sous chaque champ. React Hook Form met déjà le focus sur le premier champ en erreur (shouldFocusError par défaut), le défilement explicite est facultatif.
|
||||
|
||||
### DLC-13
|
||||
|
||||
**Mobile : « Valider (Sortir) » proposé à tous, refusé en silence aux employés et sans confirmation** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:121`
|
||||
- **Constat :** validateMutation (l.121-127) n'a pas d'onError ; le menu (l.329-343) s'affiche pour tout rôle dès que statusFilter !== 'valides'. Le serveur répond 403 aux employés (routes.ts:2984-2987). Action définitive (aucune route d'annulation) sans AlertDialog.
|
||||
- **Impact :** L'employé clique, rien ne se passe (aucun message) ; un manager peut valider par erreur un produit d'un simple tap sans retour possible.
|
||||
- **Recommandation :** Partie automatisable : masquer l'entrée pour les rôles non autorisés (même règle que DlcPage.tsx:990) et ajouter un onError avec toast en français. La confirmation (AlertDialog) ajoute une étape visible : c'est recommandé, mais à valider côté produit.
|
||||
|
||||
### DLC-14
|
||||
|
||||
**Compteurs incohérents avec les listes : « Actifs » inclut « Expire bientôt », le jour J compté deux fois, stocks épuisés comptés différemment** — bug, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2238`
|
||||
- **Constat :** getDlcStats : active = expiryDate > today (l.2238-2243, inclut donc 1-15 jours) ; expiringSoon = BETWEEN today AND today+15 (l.2244-2249) ; expired = <= today (l.2250-2255) → jour J dans les deux. Les compteurs excluent stockEpuise, mais les filtres liste 'expires'/'expires_soon' (l.2000-2028) ne l'excluent pas ; le filtre 'en_cours' = > 15 jours (l.2029-2039) ≠ carte « Produits Actifs ». DlcAlertModal affiche « Produits Expirés ({dlcStats.expired}) » (l.165) au-dessus d'une liste qui contient les stocks épuisés (l.196-200).
|
||||
- **Impact :** La carte dit 12 « Actifs » mais le filtre « Actifs » en montre 5 ; la modale annonce 2 expirés et en liste 4 : l'utilisateur ne fait plus confiance aux chiffres.
|
||||
- **Recommandation :** Définir une seule fonction SQL de classement en catégories exclusives (expiré / bientôt / ok / épuisé / traité / validé) réutilisée par getDlcStats et getDlcProducts (CASE commun ou helper drizzle), basée sur CURRENT_DATE.
|
||||
|
||||
### DLC-15
|
||||
|
||||
**Desktop : le magasin sélectionné est ignoré pour les non-admins et la clé de cache des stats entre en collision avec le Dashboard** — bug, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:83`
|
||||
- **Constat :** l.83 et l.99 : `if (selectedStoreId && user?.role === 'admin') params.append("storeId", ...)`. Dashboard.tsx:232-236 utilise la même queryKey ["/api/dlc-products/stats", selectedStoreId] mais envoie storeId pour tous les rôles. Le serveur gère déjà le contrôle (routes.ts:2843-2849).
|
||||
- **Impact :** Un directeur multi-magasins voit sur la page DLC les produits de tous ses magasins mélangés (sans colonne Magasin) alors qu'il en a sélectionné un ; les cartes peuvent afficher les chiffres d'un magasin (cache Dashboard) puis basculer sur le total après refetch.
|
||||
- **Recommandation :** Toujours envoyer storeId quand il est défini ; extraire un hook partagé useDlcStats(selectedStoreId) utilisé par Dashboard et DlcPage pour garantir une seule URL par clé.
|
||||
|
||||
### DLC-16
|
||||
|
||||
**Le tableau disparaît à chaque changement de filtre ou de recherche (pas de placeholderData ni de squelette)** — perf-client, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:937`
|
||||
- **Constat :** useQuery l.79-92 sans placeholderData ; chaque nouvelle clé (statut, fournisseur, recherche) remet isLoading à true → l.937-938 `<div className="flex justify-center items-center h-32">Chargement des produits...</div>` remplace tout le tableau. authLoading affiche aussi un simple texte (l.650-652).
|
||||
- **Impact :** Clignotement et saut de mise en page à chaque frappe ; sensation de lenteur même quand la réponse est rapide.
|
||||
- **Recommandation :** placeholderData: keepPreviousData + indicateur isFetching dans l'en-tête de la carte ; squelette seulement au premier chargement.
|
||||
|
||||
### DLC-17
|
||||
|
||||
**Aucune pagination serveur : l'historique complet (y compris tous les produits validés) est chargé par défaut** — perf-api, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/storage.ts:2063`
|
||||
- **Constat :** getDlcProducts (storage.ts:1976-2086) n'a ni limit ni offset ; aucune purge des produits 'valides' (aucun autre fichier serveur ne touche dlc_products). Filtre par défaut desktop `useState("all")` (DlcPage.tsx:45) + pagination purement client usePagination (l.640-648). Mobile : toutes les cartes rendues sans pagination (mobile/DlcPage.tsx:312-347) et l'onglet « Validés » affiche tout l'historique.
|
||||
- **Impact :** La page ralentit mois après mois (téléchargement + tri + rendu de centaines/milliers de produits déjà retirés), surtout sur mobile.
|
||||
- **Recommandation :** Vue par défaut « À traiter » (exclure 'valides') ; pagination serveur (limit/offset + total) ou au minimum limiter les validés aux 30 derniers jours ; archivage/purge des validés > 90 jours.
|
||||
|
||||
### DLC-18
|
||||
|
||||
**Chaque requête DLC recharge l'utilisateur (2 requêtes) et chaque action relit le produit avec 2 jointures** — perf-serveur, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `server/routes.ts:2799`
|
||||
- **Constat :** Toutes les routes DLC font `await storage.getUserWithGroups(req.user.id)` (ex. routes.ts:2799, 2833, 2919, 2979, 3025) alors que passport.deserializeUser a déjà exécuté getUserWithGroups (server/localAuth.ts:141-143) ; getUserWithGroups = getUser + SELECT user_groups (storage.ts:371-388). Les mutations font ensuite storage.getDlcProduct(id) (3 tables, logo inclus, storage.ts:2089-2098) juste pour lire groupId.
|
||||
- **Impact :** 5 allers-retours BD par clic (2 auth + 2 rechargement user + 1 lecture) avant la mise à jour : latence cumulée sur chaque action de la liste.
|
||||
- **Recommandation :** Partie automatisable : utiliser `const user = req.user as UserWithGroups` (même donnée, calculée dans la même requête) et une méthode légère getDlcProductGroupId(id) (SELECT group_id) pour les contrôles de droits. Le UPDATE ... WHERE group_id = ANY(...) RETURNING ne permet pas de distinguer 404 et 403 sans requête supplémentaire et change les signatures de storage : non automatique.
|
||||
|
||||
### DLC-19
|
||||
|
||||
**Chaque clic d'action recharge toute la liste + les stats et désactive le bouton sur toutes les lignes** — perf-client, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:255`
|
||||
- **Constat :** 7 mutations (l.179-320) font toutes invalidateQueries ["/api/dlc-products"] et ["/api/dlc-products/stats"], sans mise à jour optimiste alors que le serveur renvoie la ligne modifiée. `disabled={markStockEpuiseMutation.isPending}` (l.1065), `disabled={markProcessedMutation.isPending}` (l.1013), etc. désactivent le bouton de toutes les lignes.
|
||||
- **Impact :** Traiter 10 produits à la suite = 20 rechargements complets ; les boutons de toutes les lignes clignotent/grisent pendant chaque action.
|
||||
- **Recommandation :** Partie sûre : désactiver uniquement la ligne concernée (`mutation.isPending && mutation.variables === product.id`). Garder l'invalidation de la liste (éventuellement refetchType 'active'). Une mise à jour optimiste n'est possible qu'en fusionnant les champs modifiés dans la ligne existante ET en conservant l'invalidation.
|
||||
|
||||
### DLC-20
|
||||
|
||||
**Invalidations au montage : la requête de stats DLC part deux fois à chaque visite du tableau de bord** — perf-api, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/components/DlcAlertModal.tsx:45`
|
||||
- **Constat :** DlcAlertModal.tsx:45-47 `useEffect(() => { queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] }); }, [selectedStoreId, queryClient]);` (la modale est toujours montée, même fermée) et Dashboard.tsx:22-26 invalide aussi ["/api/dlc-products"] et ["/api/dlc-products/stats"] au montage. invalidateQueries (cancelRefetch par défaut) relance la requête déjà en cours ; le fetch n'utilise pas le signal, donc le serveur traite les deux.
|
||||
- **Impact :** Requêtes doublées à chaque ouverture du tableau de bord et cache DLC de la page /dlc systématiquement marqué périmé.
|
||||
- **Recommandation :** Supprimer les deux useEffect comme proposé (aucun appelant n'en dépend) ; justification à corriger : refetch inutile de l'ancienne clé au changement de magasin et staleTime ignoré, pas une double requête à chaque visite.
|
||||
|
||||
### DLC-21
|
||||
|
||||
**Messages d'erreur affichés en JSON anglais avec code HTTP** — ux-simplicite, sévérité moyenne, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/lib/queryClient.ts:6`
|
||||
- **Constat :** queryClient.ts:4-7 `throw new Error(`${res.status}: ${text}`)` avec text = corps brut ; DlcPage.tsx:193 `description: error.message`. Serveur : `res.status(500).json({ message: "Failed to create DLC product" })` (routes.ts:2913), « Insufficient permissions to validate DLC products » (routes.ts:2986), « Access denied to this group » (routes.ts:2905).
|
||||
- **Impact :** L'employé voit « 403: {"message":"Insufficient permissions to validate DLC products"} » : incompréhensible pour un non-technicien.
|
||||
- **Recommandation :** Ne pas modifier le format de error.message globalement. Soit créer une classe ApiError(status, message, body) et migrer d'abord toutes les détections de 401 vers error.status ; soit ajouter un helper getUserMessage(error), qui extrait `message` du JSON après le préfixe « NNN: », et l'utiliser dans les toasts DLC. Traduire en parallèle les messages des routes DLC.
|
||||
|
||||
### DLC-22
|
||||
|
||||
**Les erreurs serveur sont déguisées en « Aucun produit » ou en compteurs à 0** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:2827`
|
||||
- **Constat :** routes.ts:2827 `res.status(500).json([])` et routes.ts:2857 `res.status(500).json({ active: 0, expiringSoon: 0, expired: 0 })`. Côté client, aucune gestion isError : DlcPage.tsx:79 `data: dlcProducts = []` puis l.939-942 « Aucun produit DLC trouvé ». Le fetch mobile (l.91-92) et DlcAlertModal (l.56-57) ne testent pas res.ok.
|
||||
- **Impact :** En cas de panne BD, l'écran affiche 0 produit expiré : l'équipe pense que tout est en ordre.
|
||||
- **Recommandation :** Renvoyer 500 avec { message } ; afficher un bandeau « Impossible de charger les produits – Réessayer » (isError + refetch) ; utiliser apiRequest partout.
|
||||
|
||||
### DLC-23
|
||||
|
||||
**Jusqu'à 5 boutons icônes colorés par ligne, deux coches quasi identiques et 4 notions qui se recouvrent** — ux-simplicite, sévérité moyenne, effort M — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:982`
|
||||
- **Constat :** Colonne Actions l.982-1113 : Edit (sans infobulle), Valider `<CheckCircle>` vert (l.1000), Marquer traité `<CheckCircle2>` bleu (l.1017), Annuler traitement `<X>` orange, Stock épuisé `<PackageX>` jaune, Restaurer `<RotateCcw>` bleu, Supprimer `<Trash2>` rouge (sans infobulle). Concepts : « Validé », « Traité », « Stock épuisé », « Supprimé ».
|
||||
- **Impact :** Un employé ne sait pas quel bouton utiliser pour « j'ai retiré le produit du rayon » ; risque d'erreurs (valider au lieu de traiter, supprimer au lieu de valider).
|
||||
- **Recommandation :** Même recommandation (une action principale libellée + menu « … ») ; corriger le décompte : 4 boutons au maximum.
|
||||
|
||||
### DLC-25
|
||||
|
||||
**Impression cachée dans des icônes de 24 px et liste imprimée différente du compteur affiché** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:887`
|
||||
- **Constat :** Boutons l.887-894 et l.907-914 : `className="h-6 w-6 p-0"` avec `<FileText className="h-3 w-3">`, sans texte ni infobulle. printExpiringSoon/printExpired utilisent expiringSoonProducts/expiredProducts calculés depuis la liste filtrée courante (l.617-637), donc dépendent du filtre statut/fournisseur/recherche et incluent stocks épuisés et traités, alors que la carte affiche stats.expiringSoon/expired du serveur.
|
||||
- **Impact :** Fonction d'impression introuvable pour la plupart des utilisateurs ; avec le filtre « Validés » ou une recherche active, l'impression des expirés sort une feuille vide ou partielle.
|
||||
- **Recommandation :** Bouton visible « Imprimer » avec libellé dans chaque carte (ou un menu Imprimer), alimenté par une requête dédiée status=expires / expires_soon indépendante des filtres.
|
||||
|
||||
### DLC-27
|
||||
|
||||
**Hiérarchie visuelle inversée : filtres avant les compteurs, compteurs non cliquables** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:817`
|
||||
- **Constat :** Ordre du rendu : en-tête (l.657), carte « Filtres » avec titre (l.818-868), puis les 3 cartes statistiques (l.871-922), puis le tableau (l.926). Les cartes n'ont pas d'onClick pour filtrer.
|
||||
- **Impact :** L'information prioritaire (combien de produits expirés ?) est sous un bloc de formulaires ; il faut 2 actions (ouvrir le Select, choisir « Expirés ») au lieu d'un clic sur la carte rouge.
|
||||
- **Recommandation :** Placer les cartes en premier, les rendre cliquables (setStatusFilter('expires') etc. avec état actif visible), et réduire les filtres à une barre compacte au-dessus du tableau (recherche + 2 selects, sans titre de carte).
|
||||
|
||||
### DLC-28
|
||||
|
||||
**Vocabulaire, couleurs et seuils d'urgence différents entre desktop, mobile et modale** — coherence-design, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:293`
|
||||
- **Constat :** Desktop filtre « Actifs » / badge « Actif » / carte « Produits Actifs » (DlcPage.tsx:835, 495, 874) vs onglet mobile « OK » (mobile l.293) ; « Expire bientôt » vs « Bientôt » vs « Expirent Bientôt » (DlcAlertModal.tsx:219) ; « Validé » vs « Valider (Sortir) » vs toast « Produit validé (retiré) » (mobile l.125, 339) ; « Code EAN13 » (DlcPage.tsx:731) vs « Gencode (Scanner si dispo) » (mobile l.448) ; rouge ≤ 3 jours sur mobile (l.265) et « URGENT » dans la modale (l.244) mais aucun seuil 3 jours sur desktop ; bouton principal bleu desktop (l.670) vs orange mobile (l.478, 488).
|
||||
- **Impact :** Un même utilisateur qui passe du téléphone au PC doit réapprendre les termes ; les couleurs ne signifient pas la même urgence.
|
||||
- **Recommandation :** Créer un module partagé (ex. client/src/lib/dlcStatus.ts) avec libellés, couleurs de badge et seuils (expiré, ≤ 3 j urgent, ≤ 15 j bientôt), et un composant <DlcStatusBadge> utilisé par les 3 écrans.
|
||||
|
||||
### DLC-29
|
||||
|
||||
**Badge « J-3 » identique pour « expire dans 3 jours » et « expiré depuis 3 jours »** — lisibilite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:264`
|
||||
- **Constat :** l.264 `<Badge variant="destructive">Expiré J{days}</Badge>` avec days négatif → « Expiré J-3 » ; l.265 `J-{days}` avec days = 3 → « J-3 » ; le jour J → « J-0 ».
|
||||
- **Impact :** Lecture ambiguë en rayon : « J-3 » signifie habituellement « dans 3 jours » ; un produit expiré peut être lu comme encore bon.
|
||||
- **Recommandation :** Libellés explicites : « Expiré depuis 3 j », « Expire aujourd'hui », « Encore 3 j ».
|
||||
|
||||
### DLC-30
|
||||
|
||||
**Mobile : l'onglet par défaut montre les produits les moins urgents et les onglets n'ont pas de compteur** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:77`
|
||||
- **Constat :** `useState("en_cours")` (l.77) = produits à plus de 15 jours ; TabsTrigger « OK / Bientôt / Expirés / Validés » en text-xs sans nombre (l.292-297).
|
||||
- **Impact :** En ouvrant l'écran, l'employé ne voit pas qu'il y a des produits expirés à retirer ; il doit tester chaque onglet.
|
||||
- **Recommandation :** Ouvrir par défaut sur « Expirés » s'il y en a, sinon « Bientôt » ; afficher le nombre dans chaque onglet (calculé depuis la liste déjà chargée) avec pastille rouge/orange.
|
||||
|
||||
### DLC-31
|
||||
|
||||
**Mobile : actions manquantes par rapport au desktop (modifier, supprimer, stock épuisé, traité)** — ux-simplicite, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:336`
|
||||
- **Constat :** Le menu « … » ne contient qu'une entrée « Valider (Sortir) » (l.336-341) ; aucune mutation stock-epuise, mark-processed, PUT ou DELETE dans le fichier, alors que la page desktop les propose (DlcPage.tsx:255-320).
|
||||
- **Impact :** En rayon avec le téléphone, l'employé ne peut ni corriger une date mal saisie, ni marquer un produit épuisé ou vérifié : il doit retourner au PC.
|
||||
- **Recommandation :** Ajouter dans le menu les actions contextuelles réutilisant un hook commun (useDlcActions) partagé avec le desktop, et une feuille d'édition réutilisant le formulaire de création.
|
||||
|
||||
### DLC-32
|
||||
|
||||
**La modale incite à marquer un produit périmé « stock épuisé » pour faire disparaître l'alerte** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/DlcAlertModal.tsx:285`
|
||||
- **Constat :** Pour les expirés, seul bouton « Stock épuisé » (l.182-193) ; aide l.285-286 : « Marquez les produits comme "stock épuisé" ... pour faire disparaître l'alerte ». La page DLC définit pourtant ce statut comme « Indique que ce produit n'est plus en stock (différent de périmé) » (DlcPage.tsx:1074). Aucune action pour les produits « Expirent bientôt » (l.224-248).
|
||||
- **Impact :** Données faussées (produits périmés enregistrés comme ruptures) et traçabilité du retrait perdue ; l'utilisateur apprend un mauvais réflexe.
|
||||
- **Recommandation :** Proposer l'action adaptée : « Retiré du rayon » (validate, si rôle autorisé) pour les expirés et « Vérifié » (mark-processed) pour les bientôt expirés ; réécrire l'aide en conséquence.
|
||||
|
||||
### DLC-36
|
||||
|
||||
**Recherche EAN : 2 allers-retours séquentiels vers une API externe, sans cache, sans délai max, code dupliqué** — perf-api, sévérité moyenne, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:132`
|
||||
- **Constat :** DlcPage.tsx:132 `fetch('/api/ffnancy/articles?ean=...')` puis l.143 `fetch('/api/ffnancy/mouvements/entrees?artNoId=...')` ; même bloc copié dans mobile/DlcPage.tsx:148-201. Proxys routes.ts:6396-6423 : `await fetch('https://api.ffnancy.fr/...')` sans AbortSignal.timeout ni cache. Aucun retour visuel si le code est inconnu (`if (!article) return;`).
|
||||
- **Impact :** 1 à plusieurs secondes après le scan avant pré-remplissage ; spinner infini si l'API externe ne répond pas ; l'utilisateur ne sait pas si le code a été reconnu.
|
||||
- **Recommandation :** Créer un endpoint serveur unique GET /api/ffnancy/ean-lookup?ean= qui enchaîne les 2 appels, matche le fournisseur en SQL et met en cache 24 h (server/cache.ts), avec timeout 5 s ; côté client un hook useEanLookup partagé basé sur useQuery ; afficher « Produit reconnu : … » ou « Code inconnu, saisissez le nom ».
|
||||
|
||||
### DLC-47
|
||||
|
||||
**La recherche desktop ne trouve pas un produit par son code EAN (le mobile oui)** — ux-simplicite, sévérité moyenne, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2049`
|
||||
- **Constat :** storage.ts:2049-2055 : la recherche porte uniquement sur `LOWER(product_name)` et `LOWER(suppliers.name)` ; pas de gencode. Le mobile cherche aussi dans `p.gencode.includes(lower)` (mobile/DlcPage.tsx:237). Placeholder desktop « Rechercher un produit... » (DlcPage.tsx:861).
|
||||
- **Impact :** Un employé qui scanne ou tape le code-barres dans la recherche obtient « Aucun produit DLC trouvé » alors que le produit existe.
|
||||
- **Recommandation :** Ajouter `gencode LIKE search || '%'` (et `name`) à la condition OR ; placeholder « Nom, code-barres ou fournisseur ».
|
||||
|
||||
### DLC-24
|
||||
|
||||
**Bouton « Exporter PDF » sans action** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:930`
|
||||
- **Constat :** l.930-933 `<Button variant="outline" size="sm"><Download className="w-4 h-4 mr-2" />Exporter PDF</Button>` : aucun onClick.
|
||||
- **Impact :** L'utilisateur clique, rien ne se passe : perte de confiance dans l'outil.
|
||||
- **Recommandation :** Le brancher sur l'impression de la liste filtrée courante (« Imprimer la liste ») ou le retirer.
|
||||
|
||||
### DLC-33
|
||||
|
||||
**Le lien vers la page DLC recharge toute l'application** — perf-client, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/DlcAlertModal.tsx:142`
|
||||
- **Constat :** l.141-143 `const handleViewDlcModule = () => { window.location.href = '/dlc'; };`
|
||||
- **Impact :** Rechargement complet du bundle, de l'authentification et de toutes les requêtes (2 à 5 s perdues) au lieu d'une navigation instantanée.
|
||||
- **Recommandation :** Utiliser la navigation wouter : `const [, setLocation] = useLocation(); setLocation('/dlc'); onClose();`.
|
||||
|
||||
### DLC-34
|
||||
|
||||
**Jargon, emojis et formulations maladroites dans la modale** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/DlcAlertModal.tsx:285`
|
||||
- **Constat :** l.285 « 💡 Conseil : Ce modal réapparaîtra... » ; toasts « ✅ Produit marqué » / « ❌ Erreur » (l.80, 89) ; titre « Alerte Produits DLC - Action Requise » (l.151) ; le jour J « Expiré depuis {Math.abs(0)} jour(s) » → « Expiré depuis 0 jour(s) » (l.178) ; « et 3 autre(s) produit(s) expiré(s)... » (l.206).
|
||||
- **Impact :** Lecture moins fluide ; « modal » et les « (s) » font technique ; « depuis 0 jour » est incohérent.
|
||||
- **Recommandation :** « Cette fenêtre reviendra tant que... », supprimer les emojis (cohérence avec les autres toasts), gérer le singulier/pluriel et « Expire aujourd'hui ».
|
||||
|
||||
### DLC-35
|
||||
|
||||
**console.log de debug dans onSubmit et dans les routes de mutation** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:348`
|
||||
- **Constat :** DlcPage.tsx:333-357 : `console.log("🏪 DLC GroupId Selection DEBUG:", { userGroupsRaw: user?.userGroups, availableStores: ... })`. Serveur : 2 console.log par action (routes.ts:3003-3013, 3044-3053, 3089-3098, 3129-3138, 3174-3183).
|
||||
- **Impact :** Données utilisateur exposées dans la console du navigateur ; logs serveur bruités à chaque clic.
|
||||
- **Recommandation :** Supprimer les logs client ; côté serveur, garder uniquement console.error ou un logger conditionné par NODE_ENV/niveau.
|
||||
|
||||
### DLC-37
|
||||
|
||||
**Fournisseurs DLC filtrés en JavaScript après lecture de toute la table** — perf-serveur, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/routes.ts:1150`
|
||||
- **Constat :** routes.ts:1150-1154 `const suppliers = await storage.getSuppliers(); ... suppliers.filter(supplier => supplier.hasDlc === true)` ; getSuppliers = `db.select().from(suppliers)` (storage.ts:467-468).
|
||||
- **Impact :** Requête et JSON plus lourds que nécessaire à chaque ouverture de la page DLC (faible tant que la table est petite).
|
||||
- **Recommandation :** Ajouter une méthode dédiée getDlcSuppliers() (WHERE has_dlc = true, ORDER BY name, colonnes id/name/codefou) dans IStorage, DatabaseStorage et MemStorage (storage.ts:3685), plutôt que de changer la signature de getSuppliers, utilisée ailleurs (routes.ts:519, 619, 2043).
|
||||
|
||||
### DLC-38
|
||||
|
||||
**Les compteurs DLC sont renvoyés en chaînes (COUNT bigint), comparaison stricte morte dans la modale** — dette-code, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `server/storage.ts:2261`
|
||||
- **Constat :** storage.ts:2238 `sql<number>`COUNT(CASE ...)`` ; drizzle node-postgres ne surcharge que DATE/TIMESTAMP/INTERVAL (session.js:24-37) donc INT8 reste une chaîne « 3 » ; `stats.active || 0` renvoie « 0 » (chaîne truthy). DlcAlertModal.tsx:133 `if (dlcStats.expired === 0 && dlcStats.expiringSoon === 0) return null;` n'est jamais vrai.
|
||||
- **Impact :** Comparaisons fragiles (=== 0, additions qui concatènent « 2 »+« 3 » = « 23 ») au moindre calcul futur.
|
||||
- **Recommandation :** Caster en SQL : `COUNT(...)::int` ou `sql<number>`...`.mapWith(Number)`.
|
||||
|
||||
### DLC-39
|
||||
|
||||
**Les stats DLC parcourent tout l'historique (validés inclus) et utilisent la date UTC** — perf-serveur, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `server/storage.ts:2229`
|
||||
- **Constat :** storage.ts:2229-2232 `let whereCondition = sql`1 = 1``, seul filtre éventuel group_id ; les exclusions (status != 'valides', stockEpuise, processed) sont dans les CASE. Date calculée par `today.toISOString().split('T')[0]` (l.2239) = date UTC (entre 0 h et 2 h en France, « aujourd'hui » vaut la veille), alors que getDlcProducts utilise `today.setHours(0,0,0,0)` heure serveur (l.1996-1997).
|
||||
- **Impact :** Coût croissant avec l'historique sur une requête appelée à chaque Dashboard ; décalage d'un jour la nuit entre compteurs et liste.
|
||||
- **Recommandation :** Partie automatisable : déplacer `status <> 'valides' AND stock_epuise IS NOT TRUE` dans le WHERE. Pour processed_until_expiry, attendre DLC-01 : les expirés traités doivent être comptés. Pour le fuseau horaire : utiliser `(now() AT TIME ZONE 'Europe/Paris')::date` dans les deux requêtes, ou définir TZ=Europe/Paris. Index partiel via une migration dédiée (non automatique).
|
||||
|
||||
### DLC-40
|
||||
|
||||
**Index dlc_products absents de shared/schema.ts (risque de suppression par drizzle-kit push)** — dette-code, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `shared/schema.ts:278`
|
||||
- **Constat :** dlcProducts = pgTable("dlc_products", {...}) (schema.ts:278-304) sans 3e argument d'index, alors que les index existent seulement en SQL : migrations/20260814_add_performance_indexes.sql:56-59 (group_id, supplier_id, expiry_date, (group_id, status)). package.json : "db:push": "drizzle-kit push" avec schema: "./shared/schema.ts".
|
||||
- **Impact :** Un `npm run db:push` peut proposer de supprimer ces index et dégrader toutes les requêtes DLC ; aucun index composite (group_id, expiry_date) pour la requête principale.
|
||||
- **Recommandation :** Déclarer dans schema.ts exactement les index existants, avec les mêmes noms (idx_dlc_products_group_id, idx_dlc_products_supplier_id, idx_dlc_products_expiry_date, idx_dlc_products_group_status), et traiter les 47 index de la migration en une seule passe. Ajouter un nouvel index composite seulement via une migration explicite.
|
||||
|
||||
### DLC-41
|
||||
|
||||
**Boutons icônes sans libellé accessible (Modifier, Supprimer, Imprimer, menu …, bouton +)** — accessibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:983`
|
||||
- **Constat :** DlcPage.tsx:983-989 `<Button variant="outline" size="sm" onClick={() => handleEdit(product)}><Edit className="w-4 h-4" /></Button>` ; l.1105-1112 Trash2 ; l.887-894 et l.907-914 FileText ; mobile/DlcPage.tsx:332 MoreVertical et l.487-492 bouton flottant Plus : aucun aria-label ni title.
|
||||
- **Impact :** Pas d'infobulle au survol pour Modifier/Supprimer/Imprimer ; lecteurs d'écran annoncent « bouton » sans nom.
|
||||
- **Recommandation :** Ajouter aria-label + title (« Modifier », « Supprimer », « Imprimer les produits expirés », « Plus d'actions », « Ajouter un produit »).
|
||||
|
||||
### DLC-42
|
||||
|
||||
**Lignes grisées par opacité 50-60 % : texte et boutons peu lisibles** — accessibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:962`
|
||||
- **Constat :** l.961-965 `product.status === "valides" ? "opacity-60 bg-gray-50" : product.processedUntilExpiry ? "opacity-60 bg-blue-50" : product.stockEpuise ? "opacity-50 bg-yellow-50" : ""` appliqué à toute la ligne, boutons d'action compris.
|
||||
- **Impact :** Contraste insuffisant (texte gris à 50 %) pour lire nom et date ; boutons Restaurer/Annuler paraissent désactivés.
|
||||
- **Recommandation :** Remplacer l'opacité par une couleur de texte atténuée (text-gray-500) sur les cellules d'information uniquement, et garder les boutons à pleine opacité.
|
||||
|
||||
### DLC-43
|
||||
|
||||
**Tableau DLC non défilable et sans colonne Magasin sur tablette / vue multi-magasins** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/ui/table.tsx:9`
|
||||
- **Constat :** table.tsx:9 `<div className="relative w-full">` (pas d'overflow-x-auto) ; use-screen-size.ts:26-28 : 768-1024 px = « tablet » → layout desktop ; tableau DlcPage.tsx:945-956 avec 7 colonnes + jusqu'à 5 boutons. Aucune colonne Magasin alors que l'admin en « Tous les magasins » voit les produits de tous les magasins.
|
||||
- **Impact :** Sur tablette, la colonne Actions déborde hors écran ; l'admin ne sait pas à quel magasin appartient un produit.
|
||||
- **Recommandation :** Envelopper le tableau dans overflow-x-auto ; afficher l'EAN sous le nom (supprimer une colonne) ; ajouter une colonne Magasin (pastille couleur) quand aucun magasin n'est sélectionné.
|
||||
|
||||
### DLC-44
|
||||
|
||||
**Message de validation « Required » en anglais si aucun fournisseur choisi** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:33`
|
||||
- **Constat :** l.33 `supplierId: z.number().min(1, "Le fournisseur est obligatoire")` sans required_error/invalid_type_error : quand le Select n'est pas renseigné la valeur est undefined et zod renvoie son message par défaut « Required ».
|
||||
- **Impact :** Message anglais incompréhensible pour l'utilisateur final.
|
||||
- **Recommandation :** `z.number({ required_error: "Choisissez un fournisseur", invalid_type_error: "Choisissez un fournisseur" }).min(1, ...)` ; faire de même pour dlcDate (`z.string({ required_error: "La date d'expiration est obligatoire" })`) ou ajouter dlcDate: "" aux defaultValues.
|
||||
|
||||
### DLC-45
|
||||
|
||||
**Code dupliqué : 7 mutations copiées-collées, 2 fonctions d'impression identiques, TooltipProvider par bouton, imports morts** — dette-code, sévérité basse, effort M
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:178`
|
||||
- **Constat :** Mutations quasi identiques l.179-320 (seuls URL et texte changent) ; printExpiringSoon/printExpired l.499-615 ; un <TooltipProvider> par bouton et par ligne (l.1006, 1032, 1058, 1083) ; imports inutilisés AlertTriangle, Eye, Tabs/TabsContent/TabsList/TabsTrigger, AlertDialogTrigger (l.9-16). Fichier de 1156 lignes.
|
||||
- **Impact :** Maintenance difficile (les corrections doivent être répétées 7 fois, d'où les incohérences) ; rendu un peu plus lourd.
|
||||
- **Recommandation :** Partie automatisable : supprimer les imports morts, retirer les TooltipProvider locaux (le global d'App.tsx suffit), factoriser printList (avec l'échappement de DLC-26) et un hook useDlcAction. Le découpage en DlcFormDialog, DlcStatsCards et DlcTable est un refactor plus large, qui entre en conflit avec DLC-03, DLC-04, DLC-16 et DLC-19 : à faire après eux.
|
||||
|
||||
### DLC-46
|
||||
|
||||
**Pages DLC desktop et mobile importées statiquement dans le bundle principal** — perf-bundle, sévérité basse, effort S — vérification : partiellement confirmé
|
||||
|
||||
- **Fichier :** `client/src/components/RouterProduction.tsx:21`
|
||||
- **Constat :** RouterProduction.tsx:21 `import DlcPage from "@/pages/DlcPage";` et l.42 `import MobileDlcPage from "@/pages/mobile/DlcPage";` — aucun React.lazy ; un appareil n'utilise qu'une des deux versions.
|
||||
- **Impact :** Code DLC (≈1 650 lignes + react-hook-form/zod) téléchargé au premier chargement même par les utilisateurs qui n'ouvrent jamais la page.
|
||||
- **Recommandation :** Passer au chargement différé page par page pour tout le routeur, avec un Suspense commun et un ErrorBoundary qui recharge la page en cas d'erreur de chargement de chunk (ou vite:preloadError → location.reload()).
|
||||
|
||||
### DLC-48
|
||||
|
||||
**Recherche mobile plantée si un produit n'a pas de productName (colonne nullable)** — bug, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:236`
|
||||
- **Constat :** mobile/DlcPage.tsx:236 `p.productName.toLowerCase().includes(lower)` alors que shared/schema.ts:281 `productName: varchar("product_name")` est nullable (seul `name` est NOT NULL).
|
||||
- **Impact :** Une seule ligne ancienne sans product_name fait planter la page mobile dès la première lettre tapée.
|
||||
- **Recommandation :** `(p.productName ?? p.name ?? '').toLowerCase()` ; à terme fusionner name/productName en une seule colonne.
|
||||
|
||||
### DLC-49
|
||||
|
||||
**Mobile : date d'expiration pré-remplie à aujourd'hui** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/mobile/DlcPage.tsx:135`
|
||||
- **Constat :** defaultValues `dlcDate: format(new Date(), 'yyyy-MM-dd')` (l.135) ; le desktop laisse le champ vide et le rend obligatoire (DlcPage.tsx:31).
|
||||
- **Impact :** Si l'employé oublie de changer la date, le produit est créé comme expirant aujourd'hui : fausse alerte immédiate et données erronées.
|
||||
- **Recommandation :** Laisser le champ vide (obligatoire) ou proposer des raccourcis explicites (+3 j, +7 j, +15 j).
|
||||
|
||||
### DLC-50
|
||||
|
||||
**Choix DLC / DDM / DLUO : DLUO est un terme obsolète qui ajoute de la confusion** — lisibilite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/pages/DlcPage.tsx:777`
|
||||
- **Constat :** Options l.777-779 « DLC (Date Limite de Consommation) », « DDM (Date de Durabilité Minimale) », « DLUO (Date Limite d'Utilisation Optimale) » ; badge du tableau en sigle seul `uppercase` (l.972-974). DLUO a été remplacée par DDM en 2014 (règlement INCO).
|
||||
- **Impact :** L'employé hésite entre DDM et DLUO (même notion) ; le sigle seul dans le tableau n'est pas explicite.
|
||||
- **Recommandation :** Ne proposer que « À consommer jusqu'au (DLC) » et « À consommer de préférence avant (DDM) » en création, garder l'affichage DLUO pour l'existant ; infobulle sur le badge.
|
||||
|
||||
### DLC-51
|
||||
|
||||
**Retour à la page 1 après chaque action qui retire une ligne de la vue filtrée** — ux-simplicite, sévérité basse, effort S
|
||||
|
||||
- **Fichier :** `client/src/components/ui/pagination.tsx:157`
|
||||
- **Constat :** pagination.tsx:156-159 `useEffect(() => { setCurrentPage(1); }, [data.length]);` ; sur DlcPage, « Marquer traité » en filtre « Expire bientôt » retire la ligne (filtre serveur storage.ts:2010-2013) → longueur change → page 1.
|
||||
- **Impact :** En traitant une liste sur plusieurs pages, l'utilisateur est renvoyé en haut à chaque clic et perd sa position.
|
||||
- **Recommandation :** Remettre la page à 1 seulement quand les filtres changent (passer une clé de filtres au hook) et sinon borner currentPage à totalPages.
|
||||