Merge pull request #563 from R0m1k3/claude/focused-volta-u3pwmj
Claude/focused volta u3pwmj
No files matched your search
@@ -21,4 +21,12 @@ 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=
|
||||
@@ -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,8 @@ RUN npx esbuild server/index.production.ts \
|
||||
--external:express-rate-limit \
|
||||
--external:memoizee \
|
||||
--external:nanoid \
|
||||
--external:date-fns
|
||||
--external:date-fns \
|
||||
--external:nodemailer
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -273,7 +273,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>
|
||||
</>
|
||||
|
||||
@@ -210,7 +210,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>
|
||||
</>
|
||||
|
||||
@@ -178,7 +178,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>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useAuthUnified() {
|
||||
console.log('🔄 Development refetch result:', {
|
||||
success: result.isSuccess,
|
||||
hasData: !!result.data,
|
||||
userId: result.data?.id
|
||||
userId: (result.data as any)?.id
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format } from "date-fns";
|
||||
import { format, type Locale } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
|
||||
/**
|
||||
* Génération des emails de relance fournisseur (rapprochement BL/Factures).
|
||||
* Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook)
|
||||
* avec le destinataire, l'objet et le corps du message déjà préremplis.
|
||||
*/
|
||||
|
||||
export interface SupplierMailDelivery {
|
||||
blNumber?: string | null;
|
||||
blAmount?: string | number | null;
|
||||
invoiceReference?: string | null;
|
||||
scheduledDate?: string | Date | null;
|
||||
deliveredDate?: string | Date | null;
|
||||
supplier?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
contact?: string | null;
|
||||
} | null;
|
||||
group?: { name?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Objet du mail : identifie la livraison concernée.
|
||||
*/
|
||||
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||
const formattedDate = deliveryDate
|
||||
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||
: "";
|
||||
|
||||
const reference = delivery.blNumber
|
||||
? `BL n° ${delivery.blNumber}`
|
||||
: formattedDate
|
||||
? `Livraison du ${formattedDate}`
|
||||
: "Livraison";
|
||||
|
||||
const parts = [
|
||||
"Demande de facture (PDF) ou BL (Excel)",
|
||||
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
|
||||
];
|
||||
|
||||
if (delivery.group?.name) {
|
||||
parts.push(delivery.group.name);
|
||||
}
|
||||
|
||||
return parts.join(" - ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Corps du mail : rappel des documents attendus + récapitulatif de la livraison.
|
||||
* Seules les informations réellement renseignées sont listées.
|
||||
*/
|
||||
export function buildSupplierMailBody(delivery: SupplierMailDelivery): string {
|
||||
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||
const formattedDate = deliveryDate
|
||||
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||
: "";
|
||||
|
||||
const blAmount =
|
||||
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
|
||||
? parseFloat(String(delivery.blAmount))
|
||||
: null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`);
|
||||
if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`);
|
||||
if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`);
|
||||
if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`);
|
||||
if (blAmount !== null && !isNaN(blAmount)) {
|
||||
details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`);
|
||||
}
|
||||
if (delivery.invoiceReference) {
|
||||
details.push(`- Référence facture : ${delivery.invoiceReference}`);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Bonjour,",
|
||||
"",
|
||||
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||
"",
|
||||
"- la facture au format PDF, ou",
|
||||
"- le bon de livraison au format Excel",
|
||||
"",
|
||||
"Livraison concernée :",
|
||||
...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]),
|
||||
"",
|
||||
"Vous en remerciant par avance.",
|
||||
"",
|
||||
// Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés
|
||||
// par la signature configurée dans Outlook sur le poste.
|
||||
"Cordialement,",
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le lien mailto complet (destinataire + objet + corps préremplis).
|
||||
*/
|
||||
export function buildSupplierMailtoUrl(
|
||||
delivery: SupplierMailDelivery,
|
||||
supplierEmail: string
|
||||
): string {
|
||||
const subject = buildSupplierMailSubject(delivery);
|
||||
// RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A),
|
||||
// sinon Outlook peut coller les lignes les unes aux autres.
|
||||
const body = buildSupplierMailBody(delivery).replace(/\n/g, "\r\n");
|
||||
|
||||
return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin).
|
||||
*/
|
||||
export function openMailClient(mailtoUrl: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = mailtoUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// Backup temporaire pour restaurer si nécessaire
|
||||
@@ -19,7 +19,6 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
@@ -68,6 +67,8 @@ export default function BLReconciliation() {
|
||||
// É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());
|
||||
// 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);
|
||||
@@ -78,6 +79,34 @@ export default function BLReconciliation() {
|
||||
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
|
||||
//
|
||||
@@ -121,52 +150,31 @@ export default function BLReconciliation() {
|
||||
|
||||
// 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 });
|
||||
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('📝 Auto-remplissage:', { 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');
|
||||
// Invalidation différée : sinon chaque auto-remplissage relance un
|
||||
// refetch complet de la liste, qui relance l'effet, qui relance des
|
||||
// vérifications... jusqu'à saturer le navigateur.
|
||||
@@ -175,8 +183,6 @@ export default function BLReconciliation() {
|
||||
.catch((error) => {
|
||||
console.error('❌ Erreur auto-remplissage:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Aucune donnée à sauvegarder');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,8 +682,36 @@ export default function BLReconciliation() {
|
||||
return supplier?.email ? String(supplier.email).trim() : '';
|
||||
};
|
||||
|
||||
// Ouvre Outlook (client mail par défaut) avec un message prérempli
|
||||
// demandant la facture au format PDF ou le BL au format Excel
|
||||
// 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);
|
||||
|
||||
@@ -690,8 +724,12 @@ export default function BLReconciliation() {
|
||||
return;
|
||||
}
|
||||
|
||||
const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail);
|
||||
openMailClient(mailtoUrl);
|
||||
// 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) => {
|
||||
@@ -1132,23 +1170,36 @@ export default function BLReconciliation() {
|
||||
<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-blue-600 hover:text-blue-700 border-blue-300'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
!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={
|
||||
supplierEmail
|
||||
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
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})`
|
||||
}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{isSending ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className={`h-4 w-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
@@ -1462,21 +1513,34 @@ export default function BLReconciliation() {
|
||||
<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-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
!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={
|
||||
supplierEmail
|
||||
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
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})`
|
||||
}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{isSending ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className={`w-4 h-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,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
|
||||
@@ -348,18 +348,18 @@ export default function DlcPage() {
|
||||
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})),
|
||||
userGroups: user?.userGroups?.map((ug: any) => ({groupId: ug.groupId, groupName: ug.group?.name})),
|
||||
availableStores: stores.map((s: any) => ({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 +377,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 +526,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 +585,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));
|
||||
@@ -620,14 +620,14 @@ export default function DlcPage() {
|
||||
// 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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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,19 +75,7 @@ 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'],
|
||||
@@ -66,16 +87,6 @@ export default function Groups() {
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
|
||||
// rapatrier l'historique complet des commandes et des livraisons.
|
||||
@@ -97,19 +108,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)) {
|
||||
@@ -143,19 +142,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)) {
|
||||
@@ -177,6 +164,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");
|
||||
@@ -225,19 +259,7 @@ export default function Groups() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
@@ -255,6 +277,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);
|
||||
};
|
||||
@@ -289,11 +323,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 {
|
||||
@@ -301,7 +341,7 @@ export default function Groups() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const handleChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
@@ -384,7 +424,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>
|
||||
@@ -419,7 +459,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>
|
||||
@@ -463,14 +503,7 @@ 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">
|
||||
<DialogHeader>
|
||||
@@ -683,27 +716,215 @@ export default function Groups() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Coordonnées du magasin (signature des mails) */}
|
||||
<div className="border-t pt-4 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 className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<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
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function NocoDBConfig() {
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
isActive: config.isActive ?? undefined,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -737,7 +737,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 +901,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 +1063,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}
|
||||
|
||||
@@ -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";
|
||||
@@ -553,7 +552,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 +572,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>
|
||||
|
||||
@@ -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
|
||||
@@ -1,42 +0,0 @@
|
||||
-- Commandes à exécuter DIRECTEMENT dans votre conteneur Docker
|
||||
-- Copiez et collez ces commandes une par une dans votre terminal
|
||||
|
||||
-- 1. Entrer dans le conteneur
|
||||
-- docker exec -it logiflow-logiflow-1 /bin/bash
|
||||
|
||||
-- 2. Une fois dans le conteneur, exécuter cette commande SQL :
|
||||
-- psql $DATABASE_URL
|
||||
|
||||
-- 3. Coller ce SQL dans psql :
|
||||
|
||||
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()
|
||||
);
|
||||
|
||||
ALTER TABLE announcements ADD CONSTRAINT announcements_priority_check
|
||||
CHECK (priority IN ('normal', 'important', 'urgent'));
|
||||
|
||||
ALTER TABLE announcements ADD CONSTRAINT announcements_author_id_fkey
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE announcements ADD CONSTRAINT announcements_group_id_fkey
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
|
||||
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 'SUCCESS: Table announcements créée avec succès!' as result;
|
||||
SELECT COUNT(*) as nb_colonnes FROM information_schema.columns WHERE table_name = 'announcements';
|
||||
|
||||
-- 4. Taper \q pour quitter psql
|
||||
-- 5. Taper exit pour quitter le conteneur
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de correction pour Docker entrypoint
|
||||
# Applique la migration directement via docker-compose exec
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 [FIX] Correction du problème docker-entrypoint.sh"
|
||||
|
||||
# Vérifier que le conteneur fonctionne
|
||||
if ! docker-compose ps | grep -q logiflow; then
|
||||
echo "❌ [FIX] Conteneur logiflow non trouvé, démarrage..."
|
||||
docker-compose up -d logiflow-db
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
echo "🔄 [FIX] Application de la migration DLC directement..."
|
||||
|
||||
# Appliquer la migration via exec
|
||||
docker-compose exec -T logiflow-db psql -U logiflow_admin -d logiflow_db << 'EOF'
|
||||
-- Migration 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);
|
||||
|
||||
-- Vérification
|
||||
SELECT 'Migration DLC appliquée' as status;
|
||||
EOF
|
||||
|
||||
echo "✅ [FIX] Migration DLC appliquée avec succès"
|
||||
|
||||
# Redémarrer le conteneur application
|
||||
echo "🔄 [FIX] Redémarrage du conteneur application..."
|
||||
docker-compose restart logiflow
|
||||
|
||||
echo "✅ [FIX] Correction terminée"
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Script pour créer la table webhook_bap_config en production
|
||||
-- À exécuter directement sur votre base PostgreSQL
|
||||
|
||||
-- Créer la table
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
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 par défaut pour l''envoi des fichiers BAP vers n8n',
|
||||
true
|
||||
) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Créer la table de suivi des migrations
|
||||
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 DO NOTHING;
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Script de correction production pour créer la table announcements
|
||||
-- À exécuter dans votre conteneur Docker
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Création de la table announcements
|
||||
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' CHECK (priority IN ('normal', 'important', 'urgent')),
|
||||
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_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éation des index
|
||||
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);
|
||||
|
||||
-- Commentaires
|
||||
COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system';
|
||||
COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent';
|
||||
COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements';
|
||||
|
||||
-- Vérification
|
||||
SELECT
|
||||
'Table announcements créée avec succès!' as message,
|
||||
COUNT(*) as nb_colonnes
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'announcements';
|
||||
|
||||
COMMIT;
|
||||
@@ -46,6 +46,19 @@ CREATE TABLE IF NOT EXISTS "groups" (
|
||||
"nocodb_supplier_column_name" varchar,
|
||||
"nocodb_due_date_column_name" varchar,
|
||||
"webhook_url" varchar(500),
|
||||
-- Coordonnées du magasin (signature des mails fournisseurs)
|
||||
"address" text,
|
||||
"phone" varchar(50),
|
||||
"logo" text,
|
||||
-- Configuration SMTP propre au magasin
|
||||
"smtp_enabled" boolean DEFAULT false,
|
||||
"smtp_host" varchar(255),
|
||||
"smtp_port" integer,
|
||||
"smtp_secure" boolean DEFAULT false,
|
||||
"smtp_user" varchar(255),
|
||||
"smtp_password" varchar(255),
|
||||
"smtp_sender_email" varchar(255),
|
||||
"smtp_sender_name" varchar(255),
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -406,6 +419,25 @@ CREATE TABLE IF NOT EXISTS "webhook_bap_config" (
|
||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Historique des mails de relance fournisseurs (rapprochement BL/Factures)
|
||||
CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"delivery_id" integer NOT NULL,
|
||||
"group_id" integer NOT NULL,
|
||||
"supplier_id" integer,
|
||||
"supplier_name" varchar(255),
|
||||
"sent_to" varchar(255) NOT NULL,
|
||||
"subject" text,
|
||||
"status" varchar(20) NOT NULL,
|
||||
"error_message" text,
|
||||
"message_id" varchar(255),
|
||||
"sent_by" varchar NOT NULL,
|
||||
"sent_by_name" varchar(255),
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- FOREIGN KEY CONSTRAINTS
|
||||
-- ============================================================================
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
-- Script d'initialisation PostgreSQL pour les annonces
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
|
||||
author_id VARCHAR(255) NOT NULL,
|
||||
group_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index pour améliorer 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
|
||||
|
||||
-- Insertion de données de test
|
||||
INSERT INTO announcements (title, content, priority, author_id, group_id) VALUES
|
||||
('Bienvenue dans le système d''informations', 'Ce nouveau système remplace l''ancienne carte des commandes clients. Vous pouvez maintenant créer et gérer des annonces importantes.', 'important', 'admin', 1),
|
||||
('Maintenance programmée', 'Une maintenance système aura lieu dimanche prochain de 2h à 4h du matin.', 'normal', 'admin', NULL)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Afficher le résultat
|
||||
SELECT COUNT(*) as total_announcements FROM announcements;
|
||||
@@ -78,6 +78,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -106,11 +107,11 @@
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/date-fns": "^2.5.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.27",
|
||||
@@ -3735,13 +3736,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/date-fns": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz",
|
||||
"integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3839,6 +3833,16 @@
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@@ -4153,9 +4157,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -4166,7 +4170,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -4233,9 +4237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -5831,14 +5835,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -5857,7 +5861,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -6095,16 +6099,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -6395,9 +6399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -6880,9 +6884,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
@@ -7096,12 +7100,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -7110,15 +7114,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@@ -7192,33 +7187,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@@ -7233,9 +7217,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7349,6 +7333,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -7537,9 +7530,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pause": {
|
||||
@@ -7652,9 +7645,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -7682,9 +7675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7701,7 +7694,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -7839,9 +7832,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7938,12 +7931,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -8564,14 +8558,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -8583,13 +8577,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -9928,9 +9922,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -9986,15 +9980,18 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -108,11 +109,11 @@
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/date-fns": "^2.5.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.27",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Production deployment script
|
||||
* Runs database migrations and starts the application
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function deployToProduction() {
|
||||
console.log('🚀 Starting production deployment...');
|
||||
|
||||
try {
|
||||
// Check if we're in production environment
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('⚠️ Warning: NODE_ENV is not set to production');
|
||||
}
|
||||
|
||||
// Check if DATABASE_URL is available
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL environment variable is required for production');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Environment variables verified');
|
||||
|
||||
// Run database migrations
|
||||
console.log('🔄 Running database migrations...');
|
||||
|
||||
const migrateScriptPath = path.join(__dirname, 'migrations', 'migrate.js');
|
||||
if (fs.existsSync(migrateScriptPath)) {
|
||||
execSync(`node ${migrateScriptPath}`, { stdio: 'inherit' });
|
||||
console.log('✅ Database migrations completed');
|
||||
} else {
|
||||
console.log('⚠️ No migration script found, skipping migrations');
|
||||
}
|
||||
|
||||
// Start the application
|
||||
console.log('🚀 Starting application...');
|
||||
execSync('npm start', { stdio: 'inherit' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Deployment failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute if this script is run directly
|
||||
if (require.main === module) {
|
||||
deployToProduction();
|
||||
}
|
||||
|
||||
module.exports = { deployToProduction };
|
||||
|
Before Width: | Height: | Size: 275 KiB |
@@ -1,33 +0,0 @@
|
||||
import fetch from 'node-fetch';
|
||||
import FormData from 'form-data';
|
||||
|
||||
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
|
||||
|
||||
async function testWebhook() {
|
||||
console.log('🚀 Testing webhook:', webhookUrl);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', Buffer.from('test content'), {
|
||||
filename: 'test.txt',
|
||||
contentType: 'text/plain'
|
||||
});
|
||||
formData.append('supplier', 'Test Supplier');
|
||||
formData.append('blNumber', '12345');
|
||||
formData.append('type', 'Facture');
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: formData.getHeaders()
|
||||
});
|
||||
|
||||
console.log('📡 Response status:', response.status);
|
||||
const text = await response.text();
|
||||
console.log('📄 Response body:', text);
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling webhook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testWebhook();
|
||||
@@ -1,27 +0,0 @@
|
||||
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
|
||||
|
||||
async function testWebhook() {
|
||||
console.log('🚀 Testing webhook (Native Fetch/FormData):', webhookUrl);
|
||||
|
||||
const formData = new FormData();
|
||||
const fileBlob = new Blob(['test content'], { type: 'text/plain' });
|
||||
formData.append('file', fileBlob, 'test.txt');
|
||||
formData.append('supplier', 'Test Supplier');
|
||||
formData.append('blNumber', '12345');
|
||||
formData.append('type', 'Facture');
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
console.log('📡 Response status:', response.status);
|
||||
const text = await response.text();
|
||||
console.log('📄 Response body:', text);
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling webhook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testWebhook();
|
||||
@@ -11,10 +11,10 @@ type AnnouncementWithRelations = DashboardMessageWithRelations;
|
||||
// Interface commune pour le stockage
|
||||
interface IAnnouncementStorage {
|
||||
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
|
||||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null>;
|
||||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
|
||||
createAnnouncement(announcement: InsertAnnouncement): Promise<DashboardMessage>;
|
||||
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
|
||||
deleteAnnouncement(id: number): Promise<void>;
|
||||
deleteAnnouncement(id: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
// Stockage PostgreSQL pour la production
|
||||
@@ -60,7 +60,7 @@ class AnnouncementDatabaseStorage implements IAnnouncementStorage {
|
||||
}));
|
||||
}
|
||||
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null> {
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
|
||||
console.log('🔍 [DB] Getting single announcement:', id);
|
||||
|
||||
const result = await db
|
||||
@@ -136,10 +136,12 @@ class AnnouncementDatabaseStorage implements IAnnouncementStorage {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteAnnouncement(id: number): Promise<void> {
|
||||
await db
|
||||
async deleteAnnouncement(id: number): Promise<boolean> {
|
||||
const deleted = await db
|
||||
.delete(dashboardMessages)
|
||||
.where(eq(dashboardMessages.id, id));
|
||||
.where(eq(dashboardMessages.id, id))
|
||||
.returning({ id: dashboardMessages.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,26 +186,6 @@ class AnnouncementMemoryStorage implements IAnnouncementStorage {
|
||||
this.announcements.set(testAnnouncement2.id, testAnnouncement2);
|
||||
}
|
||||
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null> {
|
||||
const announcement = this.announcements.get(id);
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ajouter les relations
|
||||
const users = await this.usersGetter();
|
||||
const groups = await this.groupsGetter();
|
||||
|
||||
const author = users.find(u => u.id === announcement.createdBy || u.username === announcement.createdBy);
|
||||
const group = announcement.storeId ? groups.find(g => g.id === announcement.storeId) : undefined;
|
||||
|
||||
return {
|
||||
...announcement,
|
||||
author: author || { id: announcement.createdBy, firstName: 'Utilisateur', lastName: 'Inconnu', username: announcement.createdBy } as User,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
|
||||
const announcements = Array.from(this.announcements.values());
|
||||
|
||||
@@ -329,12 +311,13 @@ class AnnouncementMemoryStorage implements IAnnouncementStorage {
|
||||
return announcementWithRelations;
|
||||
}
|
||||
|
||||
async deleteAnnouncement(id: number): Promise<void> {
|
||||
async deleteAnnouncement(id: number): Promise<boolean> {
|
||||
const deleted = this.announcements.delete(id);
|
||||
|
||||
|
||||
if (deleted) {
|
||||
console.log(`🗑️ Annonce supprimée: ID ${id} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS} restantes)`);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export function invalidateCache(pattern: string): void {
|
||||
export function setupCompression(app: any) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
// Activer la compression pour les réponses JSON
|
||||
if (req.accepts('gzip') && res.getHeader('Content-Type')?.includes('json')) {
|
||||
if (req.accepts('gzip') && String(res.getHeader('Content-Type') ?? '').includes('json')) {
|
||||
res.setHeader('Content-Encoding', 'gzip');
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Chiffrement au repos des secrets applicatifs (mot de passe SMTP des
|
||||
* magasins, jeton API NocoDB).
|
||||
*
|
||||
* - AES-256-GCM (chiffrement authentifié : toute altération en base est détectée)
|
||||
* - Clé dérivée de ENCRYPTION_KEY, avec repli sur SESSION_SECRET pour que la
|
||||
* production existante fonctionne sans nouvelle variable d'environnement
|
||||
* - Format stocké : enc:v1:<iv>:<tag>:<données>, tout en base64
|
||||
* - Les valeurs héritées en clair sont acceptées en lecture (déchiffrement
|
||||
* transparent) et re-chiffrées par le balayage de démarrage
|
||||
*
|
||||
* ATTENTION : changer ENCRYPTION_KEY/SESSION_SECRET après coup rend les
|
||||
* secrets déjà chiffrés illisibles — il faudrait alors les ressaisir.
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
// Même repli figé que les sessions : mieux vaut un chiffrement à clé
|
||||
// faible qu'un stockage en clair, et la production définit toujours
|
||||
// SESSION_SECRET via docker-compose.
|
||||
return crypto.createHash('sha256').update('logiflow-fallback-secret-key').digest();
|
||||
}
|
||||
return crypto.createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function isEncryptedSecret(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffre un secret. Les valeurs vides et les valeurs déjà chiffrées sont
|
||||
* renvoyées telles quelles (idempotent : pas de double chiffrement possible).
|
||||
*/
|
||||
export function encryptSecret(value: string | null | undefined): string | null | undefined {
|
||||
if (!value || isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déchiffre un secret. Une valeur non chiffrée (héritage d'avant le
|
||||
* chiffrement) est renvoyée telle quelle.
|
||||
*/
|
||||
export function decryptSecret(value: string | null | undefined): string | null | undefined {
|
||||
if (!value || !isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parts = value.slice(PREFIX.length).split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Secret chiffré illisible : format inattendu');
|
||||
}
|
||||
|
||||
try {
|
||||
const [iv, tag, data] = parts.map(p => Buffer.from(p, 'base64'));
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,9 @@ pool.on('error', (err) => {
|
||||
} catch (error) {
|
||||
console.error('❌ DB: Database migrations failed with error:', error);
|
||||
console.error('❌ DB: Migration failure details:', {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code
|
||||
name: (error as any)?.name,
|
||||
message: (error as any)?.message,
|
||||
code: (error as any)?.code
|
||||
});
|
||||
// Don't fail the application startup for migration errors
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import { decryptSecret } from './crypto';
|
||||
import {
|
||||
buildSupplierMailSubject,
|
||||
buildSupplierMailText,
|
||||
buildSupplierMailHtml,
|
||||
type SupplierMailDelivery,
|
||||
} from '@shared/supplierMail';
|
||||
|
||||
/**
|
||||
* Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin.
|
||||
* Chaque magasin renseigne son serveur, ses identifiants et son adresse
|
||||
* d'expédition sur sa fiche : aucun compte global n'est utilisé.
|
||||
*/
|
||||
|
||||
export interface StoreSmtpConfig {
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
logo?: string | null;
|
||||
smtpEnabled?: boolean | null;
|
||||
smtpHost?: string | null;
|
||||
smtpPort?: number | null;
|
||||
smtpSecure?: boolean | null;
|
||||
smtpUser?: string | null;
|
||||
smtpPassword?: string | null;
|
||||
smtpSenderEmail?: string | null;
|
||||
smtpSenderName?: string | null;
|
||||
}
|
||||
|
||||
export class SmtpConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SmtpConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le magasin dispose d'une configuration exploitable.
|
||||
* Renvoie la liste des champs manquants plutôt qu'un simple booléen,
|
||||
* pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner.
|
||||
*/
|
||||
export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] {
|
||||
if (!group) return ['magasin'];
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!group.smtpHost?.trim()) missing.push('serveur SMTP');
|
||||
if (!group.smtpPort) missing.push('port SMTP');
|
||||
if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le transporteur nodemailer d'un magasin.
|
||||
* L'authentification est optionnelle : certains relais internes acceptent
|
||||
* les envois sans identifiants.
|
||||
*/
|
||||
export function createTransporter(group: StoreSmtpConfig): Transporter {
|
||||
const missing = getMissingSmtpFields(group);
|
||||
if (missing.length > 0) {
|
||||
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Le mot de passe est stocké chiffré (AES-256-GCM) : déchiffré uniquement
|
||||
// ici, au moment de la connexion au serveur SMTP
|
||||
const smtpPassword = decryptSecret(group.smtpPassword);
|
||||
const hasAuth = Boolean(group.smtpUser?.trim() && smtpPassword);
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: group.smtpHost!.trim(),
|
||||
port: Number(group.smtpPort),
|
||||
secure: Boolean(group.smtpSecure),
|
||||
auth: hasAuth
|
||||
? { user: group.smtpUser!.trim(), pass: smtpPassword! }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adresse "De :" du magasin, avec son nom affiché. */
|
||||
function buildFromAddress(group: StoreSmtpConfig): string {
|
||||
const senderEmail = group.smtpSenderEmail!.trim();
|
||||
const senderName = group.smtpSenderName?.trim() || group.name?.trim();
|
||||
return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le contenu binaire d'un logo stocké en data URI.
|
||||
* Renvoie null si le logo est absent ou dans un format inattendu.
|
||||
*/
|
||||
function parseLogoDataUri(logo: string | null | undefined) {
|
||||
if (!logo) return null;
|
||||
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, mimeType, base64] = match;
|
||||
const extension = mimeType.split('/')[1]?.split('+')[0] || 'png';
|
||||
|
||||
try {
|
||||
return {
|
||||
content: Buffer.from(base64, 'base64'),
|
||||
contentType: mimeType,
|
||||
filename: `logo.${extension}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la configuration SMTP d'un magasin sans envoyer de message.
|
||||
*/
|
||||
export async function verifySmtpConfig(group: StoreSmtpConfig): Promise<void> {
|
||||
const transporter = createTransporter(group);
|
||||
try {
|
||||
await transporter.verify();
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendSupplierMailResult {
|
||||
messageId: string;
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison.
|
||||
*/
|
||||
export async function sendSupplierDocumentRequest(
|
||||
group: StoreSmtpConfig,
|
||||
delivery: SupplierMailDelivery,
|
||||
supplierEmail: string
|
||||
): Promise<SendSupplierMailResult> {
|
||||
const transporter = createTransporter(group);
|
||||
|
||||
try {
|
||||
const store = {
|
||||
name: group.name,
|
||||
address: group.address,
|
||||
phone: group.phone,
|
||||
email: group.smtpSenderEmail,
|
||||
};
|
||||
|
||||
const logo = parseLogoDataUri(group.logo);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: buildFromAddress(group),
|
||||
to: supplierEmail.trim(),
|
||||
replyTo: group.smtpSenderEmail!.trim(),
|
||||
subject: buildSupplierMailSubject(delivery),
|
||||
text: buildSupplierMailText(delivery, store),
|
||||
html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }),
|
||||
attachments: logo
|
||||
? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }]
|
||||
: [],
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: info.messageId,
|
||||
accepted: (info.accepted || []).map(String),
|
||||
rejected: (info.rejected || []).map(String),
|
||||
};
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,21 @@ async function registerProductionRoutes(app: Express): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
// Emergency admin reset endpoint (production only)
|
||||
// Emergency admin reset endpoint (production only)
|
||||
// SÉCURITÉ : n'existe que si EMERGENCY_SECRET est défini dans l'environnement.
|
||||
// L'ancien secret par défaut était codé en dur dans ce fichier, donc lisible
|
||||
// par quiconque accède au dépôt — n'importe qui pouvait réinitialiser le
|
||||
// compte admin en production.
|
||||
app.post('/api/emergency-admin-reset', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const emergencySecret = process.env.EMERGENCY_SECRET;
|
||||
if (!emergencySecret) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
|
||||
const { secret } = req.body;
|
||||
|
||||
// Require emergency secret
|
||||
const emergencySecret = process.env.EMERGENCY_SECRET || 'logiflow-admin-reset-2025';
|
||||
if (secret !== emergencySecret) {
|
||||
if (!secret || secret !== emergencySecret) {
|
||||
console.warn('🚨 Tentative de reset admin avec un secret invalide');
|
||||
return res.status(403).json({ error: 'Invalid emergency secret' });
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ export class InvoiceVerificationService {
|
||||
errorMessage?: string;
|
||||
invoiceReference?: string;
|
||||
invoiceAmount?: number;
|
||||
invoiceAmountTTC?: number;
|
||||
supplierName?: string;
|
||||
dueDate?: string | null;
|
||||
fromCache?: boolean;
|
||||
@@ -614,6 +615,7 @@ export class InvoiceVerificationService {
|
||||
errorMessage?: string;
|
||||
invoiceReference?: string;
|
||||
invoiceAmount?: number;
|
||||
invoiceAmountTTC?: number;
|
||||
supplierName?: string;
|
||||
dueDate?: string | null;
|
||||
fromCache?: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client } from 'pg';
|
||||
import { encryptSecret, isEncryptedSecret } from './crypto.js';
|
||||
|
||||
export async function runProductionMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -58,7 +59,80 @@ export async function runProductionMigrations() {
|
||||
} else {
|
||||
console.log('✅ MIGRATION: Priority column already exists, skipping migration');
|
||||
}
|
||||
|
||||
|
||||
// Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs)
|
||||
console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...');
|
||||
await client.query(`
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
|
||||
`);
|
||||
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
|
||||
|
||||
// Historique des mails de relance fournisseurs
|
||||
console.log('🔄 MIGRATION: Ensuring supplier_mail_logs table...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
delivery_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
supplier_name VARCHAR(255),
|
||||
sent_to VARCHAR(255) NOT NULL,
|
||||
subject TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
message_id VARCHAR(255),
|
||||
sent_by VARCHAR NOT NULL,
|
||||
sent_by_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`);
|
||||
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
||||
|
||||
// Chiffrement au repos des secrets encore stockés en clair
|
||||
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
||||
// les valeurs déjà au format enc:v1: sont ignorées.
|
||||
console.log('🔄 MIGRATION: Encrypting plaintext secrets at rest...');
|
||||
let encryptedCount = 0;
|
||||
|
||||
const plainSmtp = await client.query(`
|
||||
SELECT id, smtp_password FROM groups
|
||||
WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'
|
||||
`);
|
||||
for (const row of plainSmtp.rows) {
|
||||
await client.query('UPDATE groups SET smtp_password = $1 WHERE id = $2', [
|
||||
encryptSecret(row.smtp_password),
|
||||
row.id,
|
||||
]);
|
||||
encryptedCount++;
|
||||
}
|
||||
|
||||
const plainTokens = await client.query(`
|
||||
SELECT id, api_token FROM nocodb_config
|
||||
WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'
|
||||
`);
|
||||
for (const row of plainTokens.rows) {
|
||||
if (isEncryptedSecret(row.api_token)) continue;
|
||||
await client.query('UPDATE nocodb_config SET api_token = $1 WHERE id = $2', [
|
||||
encryptSecret(row.api_token),
|
||||
row.id,
|
||||
]);
|
||||
encryptedCount++;
|
||||
}
|
||||
|
||||
console.log(`✅ MIGRATION: Secrets encryption done (${encryptedCount} value(s) encrypted this run)`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
|
||||
console.error('❌ MIGRATION ERROR: Error details:', {
|
||||
|
||||
@@ -60,12 +60,52 @@ export async function runMigrations() {
|
||||
);
|
||||
|
||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
|
||||
SELECT
|
||||
SELECT
|
||||
'Configuration BAP',
|
||||
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
||||
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
|
||||
true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
},
|
||||
{
|
||||
filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql',
|
||||
content: `
|
||||
-- Coordonnées du magasin (signature des mails fournisseurs)
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
-- Configuration SMTP propre à chaque magasin
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
|
||||
`
|
||||
},
|
||||
{
|
||||
filename: '20260814000001_create_supplier_mail_logs.sql',
|
||||
content: `
|
||||
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
delivery_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
supplier_name VARCHAR(255),
|
||||
sent_to VARCHAR(255) NOT NULL,
|
||||
subject TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
message_id VARCHAR(255),
|
||||
sent_by VARCHAR NOT NULL,
|
||||
sent_by_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,23 @@ import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
|
||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||
function redactBody(body: any): any {
|
||||
if (!body || typeof body !== 'object') return body;
|
||||
const redacted = { ...body };
|
||||
for (const key of ['smtpPassword', 'apiToken', 'password']) {
|
||||
if (key in redacted && redacted[key]) redacted[key] = '[REDACTED]';
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
import {
|
||||
sendSupplierDocumentRequest,
|
||||
verifySmtpConfig,
|
||||
getMissingSmtpFields,
|
||||
} from "./emailService";
|
||||
import { buildSupplierMailSubject } from "@shared/supplierMail";
|
||||
import { db, pool } from "./db";
|
||||
import { createRequire } from "module";
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -11,6 +28,7 @@ const require = createRequire(import.meta.url);
|
||||
|
||||
console.log('🔍 Using development storage and authentication');
|
||||
|
||||
|
||||
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
|
||||
function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
@@ -139,6 +157,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
console.log('🌍 Environment detected:', environment);
|
||||
|
||||
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
|
||||
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
|
||||
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
|
||||
// nettoie une seule fois à la sortie. Le client reçoit à la place un booléen
|
||||
// smtpPasswordSet lui indiquant si un mot de passe est enregistré.
|
||||
app.use('/api', (req, res, next) => {
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = (body: any) => originalJson(stripSmtpPassword(body));
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint for Docker
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
@@ -967,7 +998,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
'user-agent': req.headers['user-agent']?.substring(0, 50) + '...'
|
||||
});
|
||||
|
||||
console.log('📋 POST /api/groups - Request body:', JSON.stringify(req.body, null, 2));
|
||||
console.log('📋 POST /api/groups - Request body:', JSON.stringify(redactBody(req.body), null, 2));
|
||||
|
||||
// Déterminer l'ID utilisateur selon l'environnement
|
||||
let userId;
|
||||
@@ -1016,7 +1047,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
console.error('❌ Failed to create group:', {
|
||||
error: error?.message || 'Unknown error',
|
||||
stack: error?.stack,
|
||||
body: req.body,
|
||||
body: redactBody(req.body),
|
||||
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
|
||||
});
|
||||
|
||||
@@ -1042,6 +1073,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const data = insertGroupSchema.partial().parse(req.body);
|
||||
|
||||
// Le mot de passe SMTP n'est jamais renvoyé au client : un champ vide
|
||||
// signifie "inchangé", pas "effacer". On ne l'écrase que s'il est fourni.
|
||||
if (!data.smtpPassword) {
|
||||
delete (data as any).smtpPassword;
|
||||
}
|
||||
|
||||
const group = await storage.updateGroup(id, data);
|
||||
res.json(group);
|
||||
} catch (error: any) {
|
||||
@@ -1050,6 +1088,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Test de la configuration SMTP d'un magasin (aucun message envoyé)
|
||||
app.post('/api/groups/:id/test-smtp', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'manager')) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const group = await storage.getGroup(id);
|
||||
if (!group) {
|
||||
return res.status(404).json({ message: "Magasin introuvable" });
|
||||
}
|
||||
|
||||
const missing = getMissingSmtpFields(group as any);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Configuration incomplète : ${missing.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
await verifySmtpConfig(group as any);
|
||||
res.json({ success: true, message: "Connexion au serveur SMTP réussie" });
|
||||
} catch (error: any) {
|
||||
console.error("Erreur test SMTP:", error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error?.message || "Impossible de joindre le serveur SMTP"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/groups/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
@@ -1099,7 +1170,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
'content-length': req.headers['content-length']
|
||||
});
|
||||
|
||||
console.log('📋 POST /api/suppliers - Request body:', JSON.stringify(req.body, null, 2));
|
||||
console.log('📋 POST /api/suppliers - Request body:', JSON.stringify(redactBody(req.body), null, 2));
|
||||
|
||||
// Déterminer l'ID utilisateur selon l'environnement
|
||||
let userId;
|
||||
@@ -1148,7 +1219,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
console.error('❌ Failed to create supplier:', {
|
||||
error: (error as Error).message,
|
||||
stack: error.stack,
|
||||
body: req.body,
|
||||
body: redactBody(req.body),
|
||||
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
|
||||
});
|
||||
|
||||
@@ -1482,7 +1553,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
try {
|
||||
console.log('📦 Order creation started:', {
|
||||
userId: req.user?.id || req.user?.claims?.sub,
|
||||
body: req.body,
|
||||
body: redactBody(req.body),
|
||||
environment: process.env.NODE_ENV
|
||||
});
|
||||
|
||||
@@ -1529,7 +1600,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
console.error("❌ Error creating order:", {
|
||||
error: (error as Error).message,
|
||||
stack: error.stack,
|
||||
body: req.body,
|
||||
body: redactBody(req.body),
|
||||
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
|
||||
});
|
||||
|
||||
@@ -2254,6 +2325,144 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
});
|
||||
|
||||
// Route de vérification de facture NocoDB
|
||||
// Envoi au fournisseur de la demande de facture (PDF) ou de BL (Excel)
|
||||
// via le serveur SMTP configuré sur la fiche du magasin de la livraison
|
||||
app.post('/api/deliveries/:id/send-supplier-mail', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const deliveryId = parseInt(req.params.id);
|
||||
const delivery = await storage.getDelivery(deliveryId);
|
||||
|
||||
if (!delivery) {
|
||||
return res.status(404).json({ message: "Livraison introuvable" });
|
||||
}
|
||||
|
||||
if (!hasPermission(user.role, 'deliveries', 'view')) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
// Hors admin, l'utilisateur doit appartenir au magasin de la livraison
|
||||
if (user.role !== 'admin') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
if (!userGroupIds.includes(delivery.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this group" });
|
||||
}
|
||||
}
|
||||
|
||||
// Adresse du fournisseur : celle enregistrée sur sa fiche
|
||||
const supplierEmail = delivery.supplier?.email?.trim();
|
||||
if (!supplierEmail) {
|
||||
return res.status(400).json({
|
||||
message: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration SMTP du magasin (mot de passe inclus : usage serveur uniquement)
|
||||
const group = await storage.getGroup(delivery.groupId);
|
||||
if (!group) {
|
||||
return res.status(404).json({ message: "Magasin de la livraison introuvable" });
|
||||
}
|
||||
|
||||
if (!(group as any).smtpEnabled) {
|
||||
return res.status(400).json({
|
||||
message: `L'envoi de mails n'est pas activé pour le magasin ${group.name}. Renseignez la configuration SMTP sur sa fiche.`
|
||||
});
|
||||
}
|
||||
|
||||
const missing = getMissingSmtpFields(group as any);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
message: `Configuration SMTP incomplète pour ${group.name} : ${missing.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Historisation de la tentative, succès comme échec — un échec de
|
||||
// journalisation ne doit jamais faire échouer (ni annuler) l'envoi
|
||||
const senderName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
|| user.username || user.id;
|
||||
const logAttempt = async (status: 'sent' | 'failed', extra: { messageId?: string; errorMessage?: string }) => {
|
||||
try {
|
||||
await storage.createSupplierMailLog({
|
||||
deliveryId,
|
||||
groupId: delivery.groupId,
|
||||
supplierId: delivery.supplierId ?? null,
|
||||
supplierName: delivery.supplier?.name || null,
|
||||
sentTo: supplierEmail,
|
||||
subject: buildSupplierMailSubject(delivery as any),
|
||||
status,
|
||||
errorMessage: extra.errorMessage || null,
|
||||
messageId: extra.messageId || null,
|
||||
sentBy: user.id,
|
||||
sentByName: senderName,
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('⚠️ Historisation du mail fournisseur impossible:', logError);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
|
||||
|
||||
await logAttempt('sent', { messageId: result.messageId });
|
||||
|
||||
console.log('📧 Mail fournisseur envoyé:', {
|
||||
deliveryId,
|
||||
supplier: delivery.supplier?.name,
|
||||
to: supplierEmail,
|
||||
store: group.name,
|
||||
messageId: result.messageId
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sentTo: supplierEmail,
|
||||
supplierName: delivery.supplier?.name || null,
|
||||
messageId: result.messageId
|
||||
});
|
||||
} catch (sendError: any) {
|
||||
await logAttempt('failed', { errorMessage: sendError?.message || 'Erreur inconnue' });
|
||||
throw sendError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur envoi mail fournisseur:", error);
|
||||
res.status(500).json({
|
||||
message: error?.message || "Impossible d'envoyer le mail au fournisseur"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Historique des relances fournisseurs, restreint aux magasins de l'utilisateur
|
||||
app.get('/api/supplier-mail-logs', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const deliveryId = req.query.deliveryId ? parseInt(req.query.deliveryId as string) : undefined;
|
||||
|
||||
let groupIds: number[] | undefined;
|
||||
if (user.role !== 'admin') {
|
||||
groupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
if (groupIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
} else if (req.query.storeId) {
|
||||
groupIds = [parseInt(req.query.storeId as string)];
|
||||
}
|
||||
|
||||
const logs = await storage.getSupplierMailLogs(groupIds, deliveryId);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error("Erreur lecture historique mails fournisseurs:", error);
|
||||
res.status(500).json({ message: "Failed to fetch supplier mail logs" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
@@ -2479,7 +2688,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
|
||||
// Vérifier que la livraison existe
|
||||
const delivery = await storage.getDeliveryById(id);
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) {
|
||||
return res.status(404).json({ message: "Delivery not found" });
|
||||
}
|
||||
@@ -4520,7 +4729,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
res.json({ message: "Publicity deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error(`❌ [API] Error deleting publicity ${publicityId}:`, error);
|
||||
res.status(500).json({ message: "Failed to delete publicity", error: error.message });
|
||||
res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4559,7 +4768,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
res.json({ message: "Publicity deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error(`❌ [API-POST] Error deleting publicity ${publicityId} via POST:`, error);
|
||||
res.status(500).json({ message: "Failed to delete publicity", error: error.message });
|
||||
res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5349,8 +5558,14 @@ RÉSUMÉ DU SCAN
|
||||
});
|
||||
|
||||
// Emergency migration route for SAV priority column
|
||||
app.post('/api/admin/emergency-migration', async (req, res) => {
|
||||
// SÉCURITÉ : réservée aux administrateurs authentifiés (était accessible sans login)
|
||||
app.post('/api/admin/emergency-migration', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
console.log('🚨 EMERGENCY: Forcing SAV migration execution...');
|
||||
|
||||
// Import migration function
|
||||
@@ -6010,7 +6225,7 @@ RÉSUMÉ DU SCAN
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
filters.groupIds = filters.groupIds
|
||||
? filters.groupIds.filter(id => userGroupIds.includes(id))
|
||||
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
|
||||
: userGroupIds;
|
||||
}
|
||||
|
||||
@@ -6041,7 +6256,7 @@ RÉSUMÉ DU SCAN
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
filters.groupIds = filters.groupIds
|
||||
? filters.groupIds.filter(id => userGroupIds.includes(id))
|
||||
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
|
||||
: userGroupIds;
|
||||
}
|
||||
|
||||
@@ -6070,7 +6285,7 @@ RÉSUMÉ DU SCAN
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
filters.groupIds = filters.groupIds
|
||||
? filters.groupIds.filter(id => userGroupIds.includes(id))
|
||||
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
|
||||
: userGroupIds;
|
||||
}
|
||||
|
||||
@@ -6123,7 +6338,7 @@ RÉSUMÉ DU SCAN
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
filters.groupIds = filters.groupIds
|
||||
? filters.groupIds.filter(id => userGroupIds.includes(id))
|
||||
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
|
||||
: userGroupIds;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Nettoyage des données sensibles avant envoi au client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retire récursivement le mot de passe SMTP des réponses API et le remplace par
|
||||
* un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux
|
||||
* imbriqués car les magasins apparaissent sous plusieurs formes : liste de
|
||||
* groupes, champ "group" d'une livraison ou d'une commande, relations
|
||||
* utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une
|
||||
* seule fois à la sortie.
|
||||
*/
|
||||
export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Les structures cycliques sont renvoyées telles quelles plutôt que de
|
||||
// faire boucler la récursion
|
||||
if (seen.has(value)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => stripSmtpPassword(item, depth + 1, seen));
|
||||
}
|
||||
|
||||
// Ne pas dénaturer les types non sérialisables en objets simples
|
||||
if (value instanceof Date || Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === 'smtpPassword' || key === 'smtp_password') {
|
||||
result.smtpPasswordSet = Boolean(entry);
|
||||
continue;
|
||||
}
|
||||
result[key] = stripSmtpPassword(entry, depth + 1, seen);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
dashboardMessages,
|
||||
announcements,
|
||||
nocodbConfig,
|
||||
supplierMailLogs,
|
||||
invoiceVerificationCache,
|
||||
reconciliationComments,
|
||||
savTickets,
|
||||
@@ -56,12 +57,16 @@ import {
|
||||
type ReconciliationComment,
|
||||
type InsertReconciliationComment,
|
||||
type ReconciliationCommentWithRelations,
|
||||
type DashboardMessage,
|
||||
type NocodbConfig,
|
||||
type InsertNocodbConfig,
|
||||
type SupplierMailLog,
|
||||
type InsertSupplierMailLog,
|
||||
type InvoiceVerificationCache,
|
||||
type InsertInvoiceVerificationCache,
|
||||
type SavTicket,
|
||||
type InsertSavTicket,
|
||||
type SavTicketHistory,
|
||||
type InsertSavTicketHistory,
|
||||
type SavTicketWithRelations,
|
||||
type SavTicketHistoryWithCreator,
|
||||
@@ -83,6 +88,7 @@ import {
|
||||
type Contact,
|
||||
type InsertContact,
|
||||
} from "@shared/schema";
|
||||
import { encryptSecret, decryptSecret } from "./crypto";
|
||||
import { db } from "./db";
|
||||
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
|
||||
import { getAnnouncementStorage } from "./announcementStorage";
|
||||
@@ -198,7 +204,7 @@ export interface IStorage {
|
||||
|
||||
// Client call tracking
|
||||
getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]>;
|
||||
markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder>;
|
||||
markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder>;
|
||||
|
||||
// DLC Product operations
|
||||
getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise<DlcProductWithRelations[]>;
|
||||
@@ -224,8 +230,8 @@ export interface IStorage {
|
||||
// Announcement operations
|
||||
createAnnouncement(announcement: InsertAnnouncement): Promise<Announcement>;
|
||||
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
|
||||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined>;
|
||||
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations>;
|
||||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
|
||||
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
|
||||
deleteAnnouncement(id: number): Promise<boolean>;
|
||||
|
||||
// Avoir operations
|
||||
@@ -270,6 +276,10 @@ export interface IStorage {
|
||||
deleteOldWeatherData(daysToKeep: number): Promise<void>;
|
||||
clearWeatherCache(): Promise<void>;
|
||||
|
||||
// Historique des mails fournisseurs (rapprochement)
|
||||
createSupplierMailLog(log: InsertSupplierMailLog): Promise<SupplierMailLog>;
|
||||
getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]>;
|
||||
|
||||
// Webhook BAP Configuration
|
||||
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
|
||||
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
|
||||
@@ -428,14 +438,21 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
async createGroup(groupData: InsertGroup): Promise<Group> {
|
||||
const [group] = await db.insert(groups).values(groupData).returning();
|
||||
// Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au
|
||||
// moment de l'envoi d'un mail (emailService)
|
||||
const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) };
|
||||
const [group] = await db.insert(groups).values(values).returning();
|
||||
return group;
|
||||
}
|
||||
|
||||
async updateGroup(id: number, groupData: Partial<InsertGroup>): Promise<Group> {
|
||||
const values = { ...groupData };
|
||||
if (values.smtpPassword !== undefined) {
|
||||
values.smtpPassword = encryptSecret(values.smtpPassword);
|
||||
}
|
||||
const [group] = await db
|
||||
.update(groups)
|
||||
.set({ ...groupData, updatedAt: new Date() })
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(groups.id, id))
|
||||
.returning();
|
||||
return group;
|
||||
@@ -1506,7 +1523,7 @@ export class DatabaseStorage implements IStorage {
|
||||
// LOG: Debug des publicités récupérées
|
||||
console.log(`📋 PUBLICITES FETCHED: ${results.length} résultats pour année ${year || 'toutes'}`);
|
||||
if (results.length > 0) {
|
||||
console.log('🔍 PREMIERS RESULTATS:', results.slice(0, 3).map((p, i) => `${i + 1}. N°${p.pubNumber} - ${p.designation}`));
|
||||
console.log('🔍 PREMIERS RESULTATS:', results.slice(0, 3).map((p: any, i: any) => `${i + 1}. N°${p.pubNumber} - ${p.designation}`));
|
||||
}
|
||||
|
||||
const publicityIds = results.map((p: any) => p.id);
|
||||
@@ -1523,7 +1540,7 @@ export class DatabaseStorage implements IStorage {
|
||||
: [];
|
||||
|
||||
// Sort by pubNumber as integer on the server side for consistency
|
||||
const sortedResults = results.sort((a, b) => {
|
||||
const sortedResults = results.sort((a: any, b: any) => {
|
||||
const numA = parseInt(a.pubNumber) || 0;
|
||||
const numB = parseInt(b.pubNumber) || 0;
|
||||
return numA - numB;
|
||||
@@ -1622,13 +1639,26 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
// NocoDB Configuration operations
|
||||
// Le jeton API est chiffré en base ; les lecteurs (vérification de facture,
|
||||
// page d'admin) reçoivent la valeur en clair comme avant le chiffrement
|
||||
private decryptNocodbConfig<T extends NocodbConfig | undefined>(config: T): T {
|
||||
if (!config) return config;
|
||||
try {
|
||||
return { ...config, apiToken: decryptSecret(config.apiToken) } as T;
|
||||
} catch (error) {
|
||||
console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error);
|
||||
return { ...config, apiToken: '' } as T;
|
||||
}
|
||||
}
|
||||
|
||||
async getNocodbConfigs(): Promise<NocodbConfig[]> {
|
||||
return await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
|
||||
const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
|
||||
return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c));
|
||||
}
|
||||
|
||||
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
|
||||
const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id));
|
||||
return config;
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
|
||||
@@ -1639,8 +1669,9 @@ export class DatabaseStorage implements IStorage {
|
||||
.where(eq(nocodbConfig.isActive, true))
|
||||
.limit(1);
|
||||
|
||||
console.log('🔧 Configuration NocoDB active récupérée:', config);
|
||||
return config;
|
||||
// Ne pas tracer le jeton en clair dans les logs
|
||||
console.log('🔧 Configuration NocoDB active récupérée:', config?.id, config?.name);
|
||||
return this.decryptNocodbConfig(config);
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur récupération config NocoDB:', error);
|
||||
return undefined;
|
||||
@@ -1648,17 +1679,22 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
async createNocodbConfig(configData: InsertNocodbConfig): Promise<NocodbConfig> {
|
||||
const [config] = await db.insert(nocodbConfig).values(configData).returning();
|
||||
return config;
|
||||
const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' };
|
||||
const [config] = await db.insert(nocodbConfig).values(values).returning();
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async updateNocodbConfig(id: number, configData: Partial<InsertNocodbConfig>): Promise<NocodbConfig> {
|
||||
const values = { ...configData };
|
||||
if (values.apiToken !== undefined) {
|
||||
values.apiToken = encryptSecret(values.apiToken) || '';
|
||||
}
|
||||
const [config] = await db
|
||||
.update(nocodbConfig)
|
||||
.set({ ...configData, updatedAt: new Date() })
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(nocodbConfig.id, id))
|
||||
.returning();
|
||||
return config;
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async deleteNocodbConfig(id: number): Promise<void> {
|
||||
@@ -1913,7 +1949,7 @@ export class DatabaseStorage implements IStorage {
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(customerOrders.createdAt));
|
||||
|
||||
return results.map(result => ({
|
||||
return results.map((result: any) => ({
|
||||
...result.customerOrder,
|
||||
supplier: result.supplier!,
|
||||
group: result.group!,
|
||||
@@ -1967,8 +2003,8 @@ export class DatabaseStorage implements IStorage {
|
||||
in15Days.setHours(23, 59, 59, 999); // End of day
|
||||
conditions.push(
|
||||
and(
|
||||
gt(dlcProducts.expiryDate, today),
|
||||
lte(dlcProducts.expiryDate, in15Days),
|
||||
gt(dlcProducts.expiryDate, today as any),
|
||||
lte(dlcProducts.expiryDate, in15Days as any),
|
||||
ne(dlcProducts.status, 'valides'),
|
||||
or(
|
||||
isNull(dlcProducts.processedUntilExpiry),
|
||||
@@ -1981,7 +2017,7 @@ export class DatabaseStorage implements IStorage {
|
||||
// Ne pas afficher les produits déjà traités (processedUntilExpiry = true)
|
||||
conditions.push(
|
||||
and(
|
||||
lte(dlcProducts.expiryDate, today),
|
||||
lte(dlcProducts.expiryDate, today as any),
|
||||
ne(dlcProducts.status, 'valides'),
|
||||
or(
|
||||
isNull(dlcProducts.processedUntilExpiry),
|
||||
@@ -1996,7 +2032,7 @@ export class DatabaseStorage implements IStorage {
|
||||
in15Days.setHours(23, 59, 59, 999);
|
||||
conditions.push(
|
||||
and(
|
||||
gt(dlcProducts.expiryDate, in15Days),
|
||||
gt(dlcProducts.expiryDate, in15Days as any),
|
||||
ne(dlcProducts.status, 'valides')
|
||||
)
|
||||
);
|
||||
@@ -2266,7 +2302,7 @@ export class DatabaseStorage implements IStorage {
|
||||
console.log('📋 DatabaseStorage.getTasks - Raw results:', {
|
||||
resultCount: results.length,
|
||||
userRole,
|
||||
sampleTasks: results.slice(0, 2).map(r => ({
|
||||
sampleTasks: results.slice(0, 2).map((r: any) => ({
|
||||
taskExists: !!r.task,
|
||||
taskId: r.task?.id,
|
||||
title: r.task?.title,
|
||||
@@ -2377,7 +2413,7 @@ export class DatabaseStorage implements IStorage {
|
||||
|
||||
// Gestion spéciale des dates selon les types PostgreSQL
|
||||
if (taskData.startDate !== undefined) {
|
||||
if (taskData.startDate === '' || taskData.startDate === null) {
|
||||
if ((taskData.startDate as any) === '' || taskData.startDate === null) {
|
||||
cleanData.startDate = null;
|
||||
} else {
|
||||
// start_date est un timestamp en PostgreSQL - on peut passer une date
|
||||
@@ -2386,7 +2422,7 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
if (taskData.dueDate !== undefined) {
|
||||
if (taskData.dueDate === '' || taskData.dueDate === null) {
|
||||
if ((taskData.dueDate as any) === '' || taskData.dueDate === null) {
|
||||
cleanData.dueDate = null;
|
||||
} else {
|
||||
// due_date est un timestamp en PostgreSQL - convertir en Date object
|
||||
@@ -2463,7 +2499,7 @@ export class DatabaseStorage implements IStorage {
|
||||
return await announcementStorage.getAnnouncements(groupIds);
|
||||
}
|
||||
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined> {
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
|
||||
const announcementStorage = getAnnouncementStorage(
|
||||
() => this.getUsers(),
|
||||
() => this.getGroups()
|
||||
@@ -2471,7 +2507,7 @@ export class DatabaseStorage implements IStorage {
|
||||
return await announcementStorage.getAnnouncement(id);
|
||||
}
|
||||
|
||||
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations> {
|
||||
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
|
||||
const announcementStorage = getAnnouncementStorage(
|
||||
() => this.getUsers(),
|
||||
() => this.getGroups()
|
||||
@@ -2665,7 +2701,7 @@ export class DatabaseStorage implements IStorage {
|
||||
|
||||
// Get history for each ticket
|
||||
const ticketsWithHistory = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
results.map(async (result: any) => {
|
||||
const history = await this.getSavTicketHistory(result.ticket.id);
|
||||
return {
|
||||
...result.ticket,
|
||||
@@ -2826,7 +2862,7 @@ export class DatabaseStorage implements IStorage {
|
||||
};
|
||||
|
||||
// Process status results
|
||||
statusResults.forEach(result => {
|
||||
statusResults.forEach((result: any) => {
|
||||
const count = Number(result.count || 0);
|
||||
|
||||
if (result.status === 'nouveau') {
|
||||
@@ -2839,7 +2875,7 @@ export class DatabaseStorage implements IStorage {
|
||||
});
|
||||
|
||||
// Process priority results for critical tickets
|
||||
priorityResults.forEach(result => {
|
||||
priorityResults.forEach((result: any) => {
|
||||
const count = Number(result.count || 0);
|
||||
|
||||
if (result.priority === 'critique') {
|
||||
@@ -2912,6 +2948,29 @@ export class DatabaseStorage implements IStorage {
|
||||
console.log('🧹 Weather cache cleared due to location change');
|
||||
}
|
||||
|
||||
// Historique des mails fournisseurs (rapprochement)
|
||||
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
|
||||
const [log] = await db.insert(supplierMailLogs).values(logData).returning();
|
||||
return log;
|
||||
}
|
||||
|
||||
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
|
||||
const conditions = [];
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
conditions.push(inArray(supplierMailLogs.groupId, groupIds));
|
||||
}
|
||||
if (deliveryId !== undefined) {
|
||||
conditions.push(eq(supplierMailLogs.deliveryId, deliveryId));
|
||||
}
|
||||
|
||||
let query = db.select().from(supplierMailLogs).$dynamic();
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
// Borné : la page de rapprochement n'a besoin que de l'historique récent
|
||||
return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500);
|
||||
}
|
||||
|
||||
// Webhook BAP Configuration
|
||||
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
|
||||
const [config] = await db.select().from(webhookBapConfig).limit(1);
|
||||
@@ -2998,7 +3057,7 @@ export class DatabaseStorage implements IStorage {
|
||||
.where(eq(reconciliationComments.deliveryId, deliveryId))
|
||||
.orderBy(desc(reconciliationComments.createdAt));
|
||||
|
||||
return comments.map(comment => ({
|
||||
return comments.map((comment: any) => ({
|
||||
...comment,
|
||||
delivery: {
|
||||
...comment.delivery,
|
||||
@@ -3187,13 +3246,13 @@ export class DatabaseStorage implements IStorage {
|
||||
reconciliationRate: deliveryStats.count ? (Number(deliveryStats.reconciled) / Number(deliveryStats.count)) * 100 : 0,
|
||||
totalAmount: Number(deliveryStats.totalAmount) || 0,
|
||||
avgDeliveryDelay: Number(deliveryStats.avgDelay) || 0,
|
||||
topSuppliers: topSuppliers.map(s => ({
|
||||
topSuppliers: topSuppliers.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
count: Number(s.count),
|
||||
amount: Number(s.amount)
|
||||
})),
|
||||
topStores: topStores.map(s => ({
|
||||
topStores: topStores.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
orders: Number(s.orders),
|
||||
@@ -3313,7 +3372,7 @@ export class DatabaseStorage implements IStorage {
|
||||
.groupBy(suppliers.id, suppliers.name)
|
||||
.orderBy(desc(sql<number>`COUNT(${deliveries.id})`));
|
||||
|
||||
return result.map(row => ({
|
||||
return result.map((row: any) => ({
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplierName,
|
||||
deliveries: Number(row.deliveries),
|
||||
@@ -4276,7 +4335,7 @@ export class MemStorage implements IStorage {
|
||||
}));
|
||||
}
|
||||
|
||||
async markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder> {
|
||||
async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder> {
|
||||
const order = this.customerOrders.get(customerOrderId);
|
||||
if (!order) {
|
||||
throw new Error(`CustomerOrder with id ${customerOrderId} not found`);
|
||||
@@ -5209,6 +5268,52 @@ export class MemStorage implements IStorage {
|
||||
console.log('🧹 DEV: Weather cache cleared due to location change');
|
||||
}
|
||||
|
||||
async markDeliveryControlValidated(id: number, userId: string): Promise<void> {
|
||||
const delivery = this.deliveries.get(id);
|
||||
if (delivery) {
|
||||
this.deliveries.set(id, {
|
||||
...delivery,
|
||||
controlValidated: true,
|
||||
controlValidatedBy: userId,
|
||||
controlValidatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Historique des mails fournisseurs (en mémoire pour le développement)
|
||||
private supplierMailLogsStore: SupplierMailLog[] = [];
|
||||
private supplierMailLogIdCounter = 1;
|
||||
|
||||
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
|
||||
const log: SupplierMailLog = {
|
||||
id: this.supplierMailLogIdCounter++,
|
||||
deliveryId: logData.deliveryId,
|
||||
groupId: logData.groupId,
|
||||
supplierId: logData.supplierId ?? null,
|
||||
supplierName: logData.supplierName ?? null,
|
||||
sentTo: logData.sentTo,
|
||||
subject: logData.subject ?? null,
|
||||
status: logData.status,
|
||||
errorMessage: logData.errorMessage ?? null,
|
||||
messageId: logData.messageId ?? null,
|
||||
sentBy: logData.sentBy,
|
||||
sentByName: logData.sentByName ?? null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
this.supplierMailLogsStore.unshift(log);
|
||||
return log;
|
||||
}
|
||||
|
||||
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
|
||||
return this.supplierMailLogsStore
|
||||
.filter(log =>
|
||||
(!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) &&
|
||||
(deliveryId === undefined || log.deliveryId === deliveryId)
|
||||
)
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
// Webhook BAP Configuration
|
||||
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
|
||||
// En développement, retourner une config par défaut
|
||||
@@ -5284,7 +5389,7 @@ export class MemStorage implements IStorage {
|
||||
return await announcementStorage.getAnnouncements(groupIds);
|
||||
}
|
||||
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined> {
|
||||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
|
||||
const announcementStorage = getAnnouncementStorage(
|
||||
async () => Array.from(this.users.values()),
|
||||
async () => Array.from(this.groups.values())
|
||||
@@ -5292,7 +5397,7 @@ export class MemStorage implements IStorage {
|
||||
return await announcementStorage.getAnnouncement(id);
|
||||
}
|
||||
|
||||
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations> {
|
||||
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
|
||||
const announcementStorage = getAnnouncementStorage(
|
||||
async () => Array.from(this.users.values()),
|
||||
async () => Array.from(this.groups.values())
|
||||
|
||||
@@ -61,6 +61,19 @@ export const groups = pgTable("groups", {
|
||||
nocodbSupplierColumnName: varchar("nocodb_supplier_column_name"), // Nom de la colonne fournisseur dans leur table
|
||||
nocodbDueDateColumnName: varchar("nocodb_due_date_column_name"), // Nom de la colonne date d'échéance dans leur table
|
||||
webhookUrl: varchar("webhook_url", { length: 500 }), // URL de webhook pour notifications par magasin
|
||||
// Coordonnées du magasin, reprises dans la signature des mails fournisseurs
|
||||
address: text("address"), // Adresse postale
|
||||
phone: varchar("phone", { length: 50 }), // Téléphone
|
||||
logo: text("logo"), // Logo en data URI (data:image/png;base64,...), intégré aux mails
|
||||
// Configuration SMTP propre au magasin (envoi des mails fournisseurs)
|
||||
smtpEnabled: boolean("smtp_enabled").default(false),
|
||||
smtpHost: varchar("smtp_host", { length: 255 }),
|
||||
smtpPort: integer("smtp_port"),
|
||||
smtpSecure: boolean("smtp_secure").default(false), // true = SSL/TLS direct (465), false = STARTTLS
|
||||
smtpUser: varchar("smtp_user", { length: 255 }),
|
||||
smtpPassword: varchar("smtp_password", { length: 255 }), // jamais renvoyé au client
|
||||
smtpSenderEmail: varchar("smtp_sender_email", { length: 255 }), // adresse expéditeur "De :"
|
||||
smtpSenderName: varchar("smtp_sender_name", { length: 255 }), // nom affiché, par défaut le nom du magasin
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
@@ -554,6 +567,17 @@ export const insertGroupSchema = createInsertSchema(groups).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}).extend({
|
||||
// Champs optionnels : le formulaire envoie "" quand ils ne sont pas renseignés
|
||||
address: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
logo: z.string().optional().nullable(),
|
||||
smtpHost: z.string().optional().nullable(),
|
||||
smtpPort: z.coerce.number().int().min(1).max(65535).optional().nullable(),
|
||||
smtpUser: z.string().optional().nullable(),
|
||||
smtpPassword: z.string().optional().nullable(),
|
||||
smtpSenderEmail: z.union([z.string().email(), z.literal("")]).optional().nullable(),
|
||||
smtpSenderName: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const insertSupplierSchema = createInsertSchema(suppliers).omit({
|
||||
@@ -961,6 +985,23 @@ export type WeatherSettings = typeof weatherSettings.$inferSelect;
|
||||
export type InsertWeatherSettings = z.infer<typeof insertWeatherSettingsSchema>;
|
||||
|
||||
// Configuration Webhook BAP
|
||||
// Historique des mails de relance envoyés aux fournisseurs (rapprochement)
|
||||
export const supplierMailLogs = pgTable("supplier_mail_logs", {
|
||||
id: serial("id").primaryKey(),
|
||||
deliveryId: integer("delivery_id").notNull(),
|
||||
groupId: integer("group_id").notNull(),
|
||||
supplierId: integer("supplier_id"),
|
||||
supplierName: varchar("supplier_name", { length: 255 }), // figé au moment de l'envoi
|
||||
sentTo: varchar("sent_to", { length: 255 }).notNull(), // adresse destinataire
|
||||
subject: text("subject"),
|
||||
status: varchar("status", { length: 20 }).notNull(), // 'sent' | 'failed'
|
||||
errorMessage: text("error_message"),
|
||||
messageId: varchar("message_id", { length: 255 }),
|
||||
sentBy: varchar("sent_by").notNull(), // id utilisateur
|
||||
sentByName: varchar("sent_by_name", { length: 255 }), // nom lisible, figé
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const webhookBapConfig = pgTable("webhook_bap_config", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull().default("Configuration BAP"),
|
||||
@@ -981,3 +1022,11 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
|
||||
// Webhook BAP Types
|
||||
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
|
||||
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
|
||||
|
||||
// Supplier mail logs
|
||||
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
export type SupplierMailLog = typeof supplierMailLogs.$inferSelect;
|
||||
export type InsertSupplierMailLog = z.infer<typeof insertSupplierMailLogSchema>;
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Contenu des mails de relance fournisseur (rapprochement BL/Factures).
|
||||
* Partagé client/serveur : le serveur construit et envoie le mail via le SMTP
|
||||
* du magasin, le client peut réutiliser l'objet pour l'affichage.
|
||||
*/
|
||||
|
||||
export interface SupplierMailDelivery {
|
||||
blNumber?: string | null;
|
||||
blAmount?: string | number | null;
|
||||
invoiceReference?: string | null;
|
||||
scheduledDate?: string | Date | null;
|
||||
deliveredDate?: string | Date | null;
|
||||
supplier?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
contact?: string | null;
|
||||
} | null;
|
||||
group?: { name?: string | null } | null;
|
||||
}
|
||||
|
||||
/** Coordonnées du magasin reprises dans la signature. */
|
||||
export interface StoreSignature {
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Formatage jj/mm/aaaa, tolérant aux valeurs absentes ou invalides. */
|
||||
function formatDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return "";
|
||||
try {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
|
||||
const day = String(dateObj.getDate()).padStart(2, "0");
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}/${month}/${dateObj.getFullYear()}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Objet du mail : identifie la livraison concernée.
|
||||
*/
|
||||
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
const reference = delivery.blNumber
|
||||
? `BL n° ${delivery.blNumber}`
|
||||
: formattedDate
|
||||
? `Livraison du ${formattedDate}`
|
||||
: "Livraison";
|
||||
|
||||
const parts = [
|
||||
"Demande de facture (PDF) ou BL (Excel)",
|
||||
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
|
||||
];
|
||||
|
||||
if (delivery.group?.name) {
|
||||
parts.push(delivery.group.name);
|
||||
}
|
||||
|
||||
return parts.join(" - ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif de la livraison : seules les informations renseignées sont listées.
|
||||
*/
|
||||
function buildDeliveryDetails(delivery: SupplierMailDelivery): string[] {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
const blAmount =
|
||||
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
|
||||
? parseFloat(String(delivery.blAmount))
|
||||
: null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (delivery.supplier?.name) details.push(`Fournisseur : ${delivery.supplier.name}`);
|
||||
if (delivery.group?.name) details.push(`Magasin : ${delivery.group.name}`);
|
||||
if (formattedDate) details.push(`Date de livraison : ${formattedDate}`);
|
||||
if (delivery.blNumber) details.push(`N° de BL : ${delivery.blNumber}`);
|
||||
if (blAmount !== null && !isNaN(blAmount)) {
|
||||
details.push(`Montant du BL : ${blAmount.toFixed(2)} €`);
|
||||
}
|
||||
if (delivery.invoiceReference) {
|
||||
details.push(`Référence facture : ${delivery.invoiceReference}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : ["(informations de livraison à préciser)"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Version texte du mail (repli pour les clients sans HTML).
|
||||
*/
|
||||
export function buildSupplierMailText(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery).map(line => `- ${line}`);
|
||||
|
||||
const signature = [store?.name, store?.address, store?.phone ? `Tél. ${store.phone}` : null, store?.email]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const lines = [
|
||||
"Bonjour,",
|
||||
"",
|
||||
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||
"",
|
||||
"- la facture au format PDF, ou",
|
||||
"- le bon de livraison au format Excel",
|
||||
"",
|
||||
"Livraison concernée :",
|
||||
...details,
|
||||
"",
|
||||
"Vous en remerciant par avance.",
|
||||
"",
|
||||
"Cordialement,",
|
||||
...(signature ? [signature] : []),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Échappement HTML des valeurs injectées dans le corps du mail. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Version HTML du mail, avec la signature du magasin et son logo.
|
||||
* Le logo est référencé par `cid:logo` : il est joint au message par
|
||||
* l'expéditeur, ce qui évite les images distantes bloquées par Outlook.
|
||||
*/
|
||||
export function buildSupplierMailHtml(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null,
|
||||
options: { hasLogo?: boolean } = {}
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery)
|
||||
.map(line => `<li>${escapeHtml(line)}</li>`)
|
||||
.join("");
|
||||
|
||||
const signatureLines = [
|
||||
store?.name ? `<strong>${escapeHtml(store.name)}</strong>` : null,
|
||||
store?.address ? escapeHtml(store.address).replace(/\n/g, "<br>") : null,
|
||||
store?.phone ? `Tél. ${escapeHtml(store.phone)}` : null,
|
||||
store?.email
|
||||
? `<a href="mailto:${escapeHtml(store.email)}">${escapeHtml(store.email)}</a>`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const logoBlock = options.hasLogo
|
||||
? '<p style="margin:0 0 8px 0;"><img src="cid:logo" alt="" style="max-width:200px;height:auto;border:0;"></p>'
|
||||
: "";
|
||||
|
||||
const signatureBlock = signatureLines.length > 0 || logoBlock
|
||||
? `${logoBlock}<p style="margin:0;color:#444;font-size:13px;line-height:1.5;">${signatureLines.join("<br>")}</p>`
|
||||
: "";
|
||||
|
||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;">
|
||||
<p>Bonjour,</p>
|
||||
<p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :</p>
|
||||
<ul>
|
||||
<li>la facture au format PDF, ou</li>
|
||||
<li>le bon de livraison au format Excel</li>
|
||||
</ul>
|
||||
<p style="margin-bottom:4px;">Livraison concernée :</p>
|
||||
<ul>${details}</ul>
|
||||
<p>Vous en remerciant par avance.</p>
|
||||
<p style="margin-bottom:12px;">Cordialement,</p>
|
||||
${signatureBlock}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script de test d'accès à la base de données
|
||||
|
||||
echo "🔍 Test d'accès à la base de données..."
|
||||
|
||||
# Test de connexion
|
||||
echo "1. Test de connexion à la base..."
|
||||
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "SELECT version();" > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Connexion à la base réussie"
|
||||
else
|
||||
echo "❌ Erreur de connexion à la base"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Liste des tables existantes
|
||||
echo "2. Tables existantes dans la base:"
|
||||
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename;
|
||||
"
|
||||
|
||||
# Vérification spécifique de la table announcements
|
||||
echo "3. Vérification de la table announcements:"
|
||||
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'announcements'
|
||||
) as announcements_exists;
|
||||
"
|
||||
|
||||
echo "✅ Test terminé!"
|
||||
@@ -1,124 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Task Update - Production Debug</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; }
|
||||
.log { margin: 10px 0; padding: 10px; background: #f5f5f5; }
|
||||
.error { background: #ffe6e6; }
|
||||
.success { background: #e6ffe6; }
|
||||
button { padding: 10px; margin: 5px; }
|
||||
textarea, input { width: 100%; margin: 5px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Task Update - Production Debug</h1>
|
||||
|
||||
<div>
|
||||
<h3>1. D'abord, connectez-vous manuellement sur LogiFlow</h3>
|
||||
<p>Ouvrez <a href="https://logiflow.ffnancy.fr" target="_blank">LogiFlow</a> dans un autre onglet et connectez-vous avec vos vrais identifiants.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>2. Test de l'endpoint problématique</h3>
|
||||
<label>Task ID à tester:</label>
|
||||
<input type="number" id="taskId" value="99" />
|
||||
|
||||
<label>JSON des données à envoyer:</label>
|
||||
<textarea id="taskData" rows="10">{
|
||||
"title": "Test Debug",
|
||||
"description": "Test de debug depuis le script",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"assignedTo": "admin",
|
||||
"startDate": "2025-08-20",
|
||||
"dueDate": null
|
||||
}</textarea>
|
||||
|
||||
<button onclick="testTaskUpdate()">Test Task Update (PUT)</button>
|
||||
<button onclick="clearLogs()">Clear Logs</button>
|
||||
</div>
|
||||
|
||||
<div id="logs"></div>
|
||||
|
||||
<script>
|
||||
function log(message, type = 'info') {
|
||||
const logs = document.getElementById('logs');
|
||||
const div = document.createElement('div');
|
||||
div.className = `log ${type}`;
|
||||
div.innerHTML = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
logs.insertBefore(div, logs.firstChild);
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('logs').innerHTML = '';
|
||||
}
|
||||
|
||||
async function testTaskUpdate() {
|
||||
const taskId = document.getElementById('taskId').value;
|
||||
const taskData = document.getElementById('taskData').value;
|
||||
|
||||
log(`Testing task update for ID: ${taskId}`);
|
||||
|
||||
try {
|
||||
// Parse JSON data
|
||||
const data = JSON.parse(taskData);
|
||||
log(`Parsed data: ${JSON.stringify(data, null, 2)}`);
|
||||
|
||||
// Test the problematic endpoint
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin', // Important pour envoyer les cookies de session
|
||||
body: taskData
|
||||
});
|
||||
|
||||
log(`Response status: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
log(`Success! Response: ${JSON.stringify(result, null, 2)}`, 'success');
|
||||
} else {
|
||||
const error = await response.text();
|
||||
log(`Error response: ${error}`, 'error');
|
||||
|
||||
// Additional debug info
|
||||
log(`Response headers: ${JSON.stringify([...response.headers.entries()])}`, 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`JavaScript error: ${error.message}`, 'error');
|
||||
log(`Stack trace: ${error.stack}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test connectivity on load
|
||||
window.onload = function() {
|
||||
log('Script loaded. Please login to LogiFlow first!');
|
||||
|
||||
// Test if we can reach the API
|
||||
fetch('/api/user')
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
log('Not authenticated - please login first', 'error');
|
||||
} else if (response.ok) {
|
||||
log('Authenticated successfully', 'success');
|
||||
return response.json();
|
||||
} else {
|
||||
log(`Unexpected status: ${response.status}`, 'error');
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
if (user) {
|
||||
log(`Logged in as: ${user.username} (${user.role})`, 'success');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
log(`Connection test failed: ${error.message}`, 'error');
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -34,9 +34,7 @@
|
||||
"vite/client",
|
||||
"express",
|
||||
"react",
|
||||
"react-dom",
|
||||
"node-fetch",
|
||||
"date-fns"
|
||||
"react-dom"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de correction urgente pour la production
|
||||
# Applique la migration DLC immédiatement
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚨 CORRECTION URGENTE - Migration DLC Production"
|
||||
echo "================================================"
|
||||
|
||||
# Vérifier que Docker Compose est disponible
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "❌ docker-compose non trouvé"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Vérifier que le conteneur PostgreSQL est en cours d'exécution
|
||||
if ! docker-compose ps | grep -q logiflow-db; then
|
||||
echo "❌ Conteneur logiflow-db non trouvé"
|
||||
echo "Containers disponibles:"
|
||||
docker-compose ps
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Conteneur PostgreSQL trouvé"
|
||||
|
||||
# Appliquer la migration DLC
|
||||
echo "🔄 Application de la migration DLC..."
|
||||
|
||||
docker-compose exec -T logiflow-db psql -U logiflow_admin -d logiflow_db << 'EOF'
|
||||
-- Migration 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);
|
||||
|
||||
-- Vérification
|
||||
SELECT 'Migration DLC appliquée avec succès' as status;
|
||||
|
||||
-- Afficher les colonnes ajoutées
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dlc_products'
|
||||
AND column_name LIKE '%stock_epuise%'
|
||||
ORDER BY column_name;
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Migration DLC appliquée avec succès"
|
||||
else
|
||||
echo "❌ Erreur lors de la migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Redémarrer l'application
|
||||
echo "🔄 Redémarrage de l'application..."
|
||||
docker-compose restart logiflow
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Application redémarrée"
|
||||
else
|
||||
echo "❌ Erreur lors du redémarrage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 CORRECTION TERMINÉE"
|
||||
echo "L'erreur 'column does not exist' devrait maintenant être résolue"
|
||||
echo ""
|
||||
echo "Vérifiez dans les logs que l'API /api/dlc-products fonctionne maintenant"
|
||||
@@ -20,13 +20,32 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "client", "src"),
|
||||
"@shared": path.resolve(import.meta.dirname, "shared"),
|
||||
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
||||
},
|
||||
},
|
||||
// En production, les console.log/debug de debug sont retirés du bundle par
|
||||
// le minifieur (console.warn et console.error sont conservés). En dev, tout
|
||||
// reste visible.
|
||||
esbuild: {
|
||||
pure: process.env.NODE_ENV === "production" ? ["console.log", "console.debug"] : [],
|
||||
},
|
||||
root: path.resolve(import.meta.dirname, "client"),
|
||||
build: {
|
||||
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Sépare les grosses dépendances stables du code applicatif :
|
||||
// elles ne changent qu'aux montées de version et restent donc en
|
||||
// cache navigateur entre deux déploiements, au lieu de faire
|
||||
// retélécharger 1,7 Mo à chaque mise en production.
|
||||
manualChunks: {
|
||||
"vendor-react": ["react", "react-dom", "wouter"],
|
||||
"vendor-query": ["@tanstack/react-query"],
|
||||
"vendor-charts": ["recharts"],
|
||||
"vendor-icons": ["lucide-react"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
|
||||