22 Commits
Author SHA1 Message Date
Claude 3eea2eb47b fix(meteo): fall back to nearest cached data when the history API fails
The dashboard widget lost the previous-year temperature and the difference:
when the day's previous-year row is not yet cached and the Visual Crossing
history call fails (quota exhausted, outage, or a plan without history
access), the route silently returned previousYear: null and the widget
hid the comparison.

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

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

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

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

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

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

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

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

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

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

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

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

TypeScript errors: 261 → 177.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+9 -1
View File
@@ -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=
+10 -1
View File
@@ -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/
+3 -2
View File
@@ -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
-24
View File
@@ -1,24 +0,0 @@
-- MIGRATION URGENTE PRODUCTION DLC
-- Exécutez cette commande directement sur votre serveur
-- Ajout des colonnes DLC stock épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Index pour performance
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
-- Initialisation des valeurs
UPDATE dlc_products
SET stock_epuise = false
WHERE stock_epuise IS NULL;
-- Vérification
SELECT
'Migration DLC terminée' as status,
COUNT(*) as colonnes_ajoutees
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at');
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

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

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

@@ -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`
}}
>
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { format } from "date-fns";
import { format, type Locale } from "date-fns";
import { fr } from "date-fns/locale";
/**
-1
View File
@@ -1 +0,0 @@
// Backup temporaire pour restaurer si nécessaire
+337 -144
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { safeFormat } from "@/lib/dateUtils";
import { Button } from "@/components/ui/button";
@@ -12,7 +12,7 @@ import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Pagination, usePagination } from "@/components/ui/pagination";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare } from "lucide-react";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
@@ -67,7 +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());
const [autoVerifiedDeliveries, setAutoVerifiedDeliveries] = useState<Set<number>>(new Set());
// Livraisons dont le mail fournisseur est en cours d'envoi
const [sendingMailDeliveries, setSendingMailDeliveries] = useState<Set<number>>(new Set());
// État pour le modal de commentaire
const [showCommentModal, setShowCommentModal] = useState(false);
@@ -78,9 +79,55 @@ 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
//
// Sans limite, une page de 200 livraisons déclenchait 200 requêtes simultanées
// (+ autant de PUT d'auto-remplissage), ce qui saturait le navigateur
// (net::ERR_INSUFFICIENT_RESOURCES). On limite donc le nombre de requêtes en
// vol et on dédoublonne les livraisons déjà en attente.
// ---------------------------------------------------------------------------
const MAX_CONCURRENT_VERIFICATIONS = 3;
const verificationQueueRef = useRef<Array<{ delivery: any; forceRefresh: boolean; silent: boolean }>>([]);
const activeVerificationsRef = useRef(0);
// Livraisons en file d'attente ou en cours (dédoublonnage synchrone)
const pendingVerificationIdsRef = useRef<Set<number>>(new Set());
// Livraisons déjà vérifiées automatiquement : jamais relancées par l'effet
const autoRequestedIdsRef = useRef<Set<number>>(new Set());
// Invalidation de cache différée : une seule fois quand la file est vidée
const needsCacheInvalidationRef = useRef(false);
// Fonction de vérification de facture
const verifyInvoiceMutation = useMutation({
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean }) => {
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: boolean }) => {
try {
const result = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, 'POST', {
invoiceReference,
@@ -103,61 +150,39 @@ 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');
// Invalider les caches (pas de refetch pour éviter boucles)
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
// 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.
needsCacheInvalidationRef.current = true;
})
.catch((error) => {
console.error('❌ Erreur auto-remplissage:', error);
});
} else {
console.log('⚠️ Aucune donnée à sauvegarder');
}
}
@@ -184,56 +209,113 @@ export default function BLReconciliation() {
return newSet;
});
toast({
title: "Erreur de vérification",
description: error instanceof Error ? error.message : 'Erreur inconnue',
variant: "destructive",
});
// Les vérifications automatiques restent silencieuses (coche rouge) :
// sinon une panne réseau génère autant de toasts que de lignes affichées.
if (!variables.silent) {
toast({
title: "Erreur de vérification",
description: error instanceof Error ? error.message : 'Erreur inconnue',
variant: "destructive",
});
}
}
});
// Fonction pour déclencher la vérification
const handleVerifyInvoice = (delivery: any, forceRefresh: boolean = false) => {
// Dépile la file d'attente en respectant la limite de requêtes simultanées
const drainVerificationQueue = () => {
while (
activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS &&
verificationQueueRef.current.length > 0
) {
const next = verificationQueueRef.current.shift();
if (!next) break;
const { delivery, forceRefresh, silent } = next;
activeVerificationsRef.current += 1;
if (import.meta.env.DEV) {
console.log('🔍 Déclenchement vérification:', {
deliveryId: delivery.id,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
supplier: delivery.supplier?.name,
enAttente: verificationQueueRef.current.length
});
}
verifyInvoiceMutation
.mutateAsync({
deliveryId: delivery.id,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
forceRefresh,
silent
})
// Les erreurs sont déjà tracées et affichées par onError
.catch(() => undefined)
.finally(() => {
activeVerificationsRef.current -= 1;
pendingVerificationIdsRef.current.delete(delivery.id);
drainVerificationQueue();
// File vidée : on rafraîchit la liste une seule fois
if (
activeVerificationsRef.current === 0 &&
verificationQueueRef.current.length === 0 &&
needsCacheInvalidationRef.current
) {
needsCacheInvalidationRef.current = false;
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
}
});
}
};
// Fonction pour déclencher la vérification (mise en file d'attente)
// silent = déclenchement automatique : aucun toast, seules les coches parlent
const handleVerifyInvoice = (
delivery: any,
forceRefresh: boolean = false,
silent: boolean = false
) => {
// Accepter soit une référence de facture soit un numéro BL
const hasInvoiceRef = delivery.invoiceReference?.trim();
const hasBlNumber = delivery.blNumber?.trim();
if (!hasInvoiceRef && !hasBlNumber) {
toast({
title: "Référence manquante",
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
variant: "destructive",
});
if (!silent) {
toast({
title: "Référence manquante",
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
variant: "destructive",
});
}
return;
}
if (!delivery.group?.nocodbTableName && !delivery.group?.nocodbConfigId && !delivery.group?.webhookUrl) {
toast({
title: "Vérification non disponible",
description: "Ce magasin n'a pas de configuration NocoDB",
variant: "destructive",
});
if (!silent) {
toast({
title: "Vérification non disponible",
description: "Ce magasin n'a pas de configuration NocoDB",
variant: "destructive",
});
}
return;
}
console.log('🔍 Déclenchement vérification:', {
deliveryId: delivery.id,
hasInvoiceRef,
hasBlNumber,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
supplier: delivery.supplier?.name
});
// Déjà en file d'attente ou en cours : on ne l'ajoute pas une seconde fois
if (pendingVerificationIdsRef.current.has(delivery.id)) {
return;
}
pendingVerificationIdsRef.current.add(delivery.id);
verificationQueueRef.current.push({ delivery, forceRefresh, silent });
setVerifyingDeliveries(prev => new Set(prev).add(delivery.id));
verifyInvoiceMutation.mutate({
deliveryId: delivery.id,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
forceRefresh
});
drainVerificationQueue();
};
// Fonction pour vérifier toutes les factures avec un bouton
@@ -251,11 +333,10 @@ export default function BLReconciliation() {
return;
}
deliveriesToVerify.forEach((delivery: any, index: number) => {
// Délai échelonné pour éviter la surcharge
setTimeout(() => {
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
}, index * 200); // 200ms entre chaque vérification
// La file d'attente limite déjà le nombre de requêtes simultanées :
// on peut tout empiler d'un coup sans surcharger le navigateur.
deliveriesToVerify.forEach((delivery: any) => {
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
});
toast({
@@ -296,97 +377,80 @@ export default function BLReconciliation() {
});
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
//
// Cet effet ne dépend QUE des données (livraisons + fournisseurs) : y ajouter
// verificationResults/verifyingDeliveries le relançait à chaque résultat reçu,
// ce qui reprogrammait en boucle les mêmes vérifications.
// Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être
// effectif immédiatement, sans attendre un re-rendu.
useEffect(() => {
if (!deliveriesWithBL.length || !suppliers.length) return;
if (import.meta.env.DEV) {
console.log('🔄 Déclenchement vérifications automatiques...');
}
// Pré-populer les résultats pour les livraisons déjà réconciliées
const newVerificationResults = { ...verificationResults };
let hasNewReconciledResults = false;
// Résultats déduits localement (sans appel réseau) pour afficher les coches
const cachedResults: Record<number, any> = {};
deliveriesWithBL.forEach((delivery: any) => {
// Si la livraison est déjà réconciliée, marquer comme vérifiée avec succès
if (delivery.reconciled && !verificationResults[delivery.id]) {
newVerificationResults[delivery.id] = {
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
if (delivery.reconciled) {
// Livraison déjà réconciliée → coche verte sans vérification
cachedResults[delivery.id] = {
exists: true,
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
fromCache: true,
permanent: true,
reconciled: true
};
hasNewReconciledResults = true;
if (import.meta.env.DEV) {
console.log(`✅ Livraison ${delivery.id} déjà réconciliée, marquée comme vérifiée`);
}
}
});
// Mettre à jour les résultats si on a de nouvelles livraisons réconciliées
if (hasNewReconciledResults) {
setVerificationResults(newVerificationResults);
}
// CAS SPÉCIAL : Livraisons réconciliées (✅) avec cellules vides
// Si reconciled=true ET (cellules vides) ET blNumber existe → auto-remplir
deliveriesWithBL.forEach((delivery: any) => {
if (delivery.reconciled) {
// CAS SPÉCIAL : réconciliée mais cellules vides → auto-remplissage (une seule fois)
const hasEmptyCells = !delivery.invoiceReference || !delivery.invoiceAmount || !delivery.dueDate;
const hasBLNumber = delivery.blNumber?.trim();
const notAlreadyAutoVerified = !autoVerifiedDeliveries.has(delivery.id);
const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id);
if (hasEmptyCells && hasBLNumber && notAlreadyAutoVerified && notCurrentlyVerifying) {
// Livraison réconciliée avec cellules vides → vérifier pour auto-remplir (UNE SEULE FOIS)
if (import.meta.env.DEV) {
console.log(`🔄 Livraison réconciliée #${delivery.id} avec cellules vides, auto-vérification (première tentative)...`);
}
// Marquer comme auto-vérifiée AVANT de lancer pour éviter les doublons
setAutoVerifiedDeliveries(prev => new Set(prev).add(delivery.id));
// Lancer la vérification sans délai
handleVerifyInvoice(delivery, false);
if (hasEmptyCells && hasBLNumber && !autoRequestedIdsRef.current.has(delivery.id)) {
autoRequestedIdsRef.current.add(delivery.id);
handleVerifyInvoice(delivery, false, true);
}
return; // Autres livraisons réconciliées = AUCUNE vérification nécessaire
return;
}
// VÉRIFICATION AUTOMATIQUE pour afficher les coches
// NE vérifier QUE les factures qui n'ont PAS encore de montant renseigné (pas encore trouvées)
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
const hasNoInvoiceAmount = !delivery.invoiceAmount; // Pas encore trouvée dans NocoDB
const notAlreadyVerified = !verificationResults[delivery.id];
const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id);
// Ne vérifier que si : a des données ET pas de montant (pas encore trouvée) ET pas déjà vérifiée
if (hasVerifiableData && hasNoInvoiceAmount && notAlreadyVerified && notCurrentlyVerifying) {
if (import.meta.env.DEV) {
console.log(`🔍 Vérification initiale ${delivery.id} (pas encore trouvée):`, {
invoiceRef: delivery.invoiceReference,
blNumber: delivery.blNumber
});
}
// Délai pour éviter de surcharger le serveur
setTimeout(() => {
handleVerifyInvoice(delivery, false);
}, Math.random() * 1000);
}
// Si la facture a déjà un montant → marquer comme trouvée (coche verte) sans vérifier
if (hasVerifiableData && delivery.invoiceAmount && !verificationResults[delivery.id]) {
newVerificationResults[delivery.id] = {
// Facture déjà trouvée (montant renseigné) → coche verte sans appel réseau
if (hasVerifiableData && delivery.invoiceAmount) {
cachedResults[delivery.id] = {
exists: true,
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
fromCache: true,
permanent: true,
invoiceAmount: delivery.invoiceAmount
};
hasNewReconciledResults = true;
return;
}
// Sinon : vérification réseau, une seule fois par livraison
if (hasVerifiableData && !autoRequestedIdsRef.current.has(delivery.id)) {
autoRequestedIdsRef.current.add(delivery.id);
handleVerifyInvoice(delivery, false, true);
}
});
}, [deliveriesWithBL, suppliers, verificationResults, verifyingDeliveries]);
// Application des résultats déduits, sans écraser ceux déjà obtenus
setVerificationResults(prev => {
const merged = { ...prev };
let changed = false;
Object.entries(cachedResults).forEach(([id, result]) => {
if (!merged[Number(id)]) {
merged[Number(id)] = result;
changed = true;
}
});
return changed ? merged : prev;
});
}, [deliveriesWithBL, suppliers]);
// Séparer les livraisons : non validées manuelles et toutes les validées
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
@@ -607,6 +671,67 @@ export default function BLReconciliation() {
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
};
// Email du fournisseur : donnée jointe à la livraison, avec repli sur la liste des fournisseurs
const getSupplierEmail = (delivery: any): string => {
const fromDelivery = delivery?.supplier?.email;
if (fromDelivery && String(fromDelivery).trim()) {
return String(fromDelivery).trim();
}
const supplier = suppliers.find((s: any) => s.id === delivery?.supplierId);
return supplier?.email ? String(supplier.email).trim() : '';
};
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
// Le mail part du serveur SMTP configuré sur la fiche du magasin.
const sendSupplierMailMutation = useMutation({
mutationFn: async (delivery: any) => {
return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST');
},
onSuccess: (result: any) => {
toast({
title: "Mail envoyé",
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
});
// Rafraîchir l'historique pour marquer la ligne comme relancée
queryClient.invalidateQueries({ queryKey: ['/api/supplier-mail-logs'] });
},
onError: (error: any) => {
toast({
title: "Envoi impossible",
description: error?.message || "Le mail n'a pas pu être envoyé",
variant: "destructive",
});
},
onSettled: (_result, _error, delivery: any) => {
setSendingMailDeliveries(prev => {
const next = new Set(prev);
next.delete(delivery.id);
return next;
});
},
});
const handleRequestDocumentsByEmail = (delivery: any) => {
const supplierEmail = getSupplierEmail(delivery);
if (!supplierEmail) {
toast({
title: "Email fournisseur manquant",
description: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}. Ajoutez-la depuis la page Contacts ou Fournisseurs.`,
variant: "destructive",
});
return;
}
// Envoi direct : on bloque la ligne le temps de la requête pour éviter
// qu'un double clic ne déclenche deux mails au même fournisseur.
if (sendingMailDeliveries.has(delivery.id)) return;
setSendingMailDeliveries(prev => new Set(prev).add(delivery.id));
sendSupplierMailMutation.mutate(delivery);
};
const handleQuickValidate = async (delivery: any) => {
try {
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
@@ -1043,6 +1168,41 @@ export default function BLReconciliation() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
const lastMail = lastMailByDelivery.get(delivery.id);
return (
<Button
variant="outline"
size="sm"
disabled={isSending}
onClick={() => handleRequestDocumentsByEmail(delivery)}
className={`h-8 w-8 p-0 ${
!supplierEmail
? 'text-gray-400 hover:text-gray-500'
: lastMail
? 'text-green-600 hover:text-green-700 border-green-300'
: 'text-blue-600 hover:text-blue-700 border-blue-300'
}`}
title={
isSending
? "Envoi en cours..."
: !supplierEmail
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
: lastMail
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
}
>
{isSending ? (
<Clock className="h-4 w-4 animate-spin" />
) : (
<Mail className={`h-4 w-4 ${lastMail ? 'fill-green-100' : ''}`} />
)}
</Button>
);
})()}
{shouldShowInvoiceButton(delivery) && (
<Button
variant="outline"
@@ -1351,6 +1511,39 @@ export default function BLReconciliation() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
const lastMail = lastMailByDelivery.get(delivery.id);
return (
<button
disabled={isSending}
onClick={() => handleRequestDocumentsByEmail(delivery)}
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
!supplierEmail
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
: lastMail
? 'text-green-600 hover:text-green-700 hover:bg-green-50'
: 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
}`}
title={
isSending
? "Envoi en cours..."
: !supplierEmail
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
: lastMail
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
}
>
{isSending ? (
<Clock className="w-4 h-4 animate-spin" />
) : (
<Mail className={`w-4 h-4 ${lastMail ? 'fill-green-100' : ''}`} />
)}
</button>
);
})()}
<button
onClick={() => handleOpenCommentModal(delivery)}
className={`transition-colors duration-200 p-1 rounded ${
-778
View File
@@ -1,778 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { safeFormat } from "@/lib/dateUtils";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
import type { Supplier } from "@shared/schema";
export default function BLReconciliation() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Vérification des permissions d'accès au module
if (!permissions.canView('reconciliation')) {
return (
<div className="p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<FileText className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Seuls les directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
</p>
</div>
</div>
</div>
</div>
);
}
const [activeTab, setActiveTab] = useState("manual");
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'],
});
// Récupérer les livraisons validées avec BL
const { data: deliveriesWithBL = [], isLoading } = useQuery({
queryKey: ['/api/deliveries/bl', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams({});
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/deliveries?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch deliveries');
}
const deliveries = await response.json();
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
},
});
// Séparer les livraisons selon le mode de rapprochement du fournisseur
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return !supplier?.automaticReconciliation;
});
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return supplier?.automaticReconciliation;
});
// Mutations pour les actions
const validateManualMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: true,
validatedAt: new Date().toISOString()
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
variant: "destructive",
});
}
});
const devalidateMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: false,
validatedAt: null
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement dévalidé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de dévalider le rapprochement",
variant: "destructive",
});
}
});
const deleteMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Ligne supprimée avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer la ligne",
variant: "destructive",
});
}
});
// Fonctions de gestion des actions
const handleValidateManual = (deliveryId: number) => {
if (!permissions.canValidate('reconciliation')) {
toast({
title: "Accès refusé",
description: "Vous n'avez pas les permissions pour valider les rapprochements",
variant: "destructive",
});
return;
}
validateManualMutation.mutate(deliveryId);
};
const handleDevalidate = (deliveryId: number) => {
if (!permissions.canEdit('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
devalidateMutation.mutate(deliveryId);
}
};
const handleDelete = (deliveryId: number) => {
if (!permissions.canDelete('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent supprimer les lignes",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
deleteMutation.mutate(deliveryId);
}
};
// Filtrage des livraisons par recherche
const filterDeliveries = (deliveries: any[]) => {
return deliveries.filter((delivery: any) => {
const searchLower = searchTerm.toLowerCase();
return (
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
);
});
};
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
// Pagination
const getCurrentPageData = (data: any[]) => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return data.slice(startIndex, endIndex);
};
const getTotalPages = (data: any[]) => {
return Math.ceil(data.length / itemsPerPage);
};
const currentManualData = getCurrentPageData(filteredManualDeliveries);
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
const totalManualPages = getTotalPages(filteredManualDeliveries);
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
// Reset page when changing tabs or searching
useEffect(() => {
setCurrentPage(1);
}, [activeTab, searchTerm]);
// Vérifier si une ligne est dévalidée (pour la coloration)
const isDevalidated = (delivery: any) => {
return delivery.reconciled === false && delivery.validatedAt === null;
};
// Composant de pagination simplifiée
const NativePagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
itemsPerPage: number;
}) => {
if (totalPages <= 1) return null;
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
<div className="flex items-center text-sm text-gray-700">
<span>
Affichage de {startItem} à {endItem} sur {totalItems} éléments
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<ChevronLeft className="w-4 h-4" />
<span>Précédent</span>
</button>
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
Page {currentPage} sur {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<span>Suivant</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
// Badge native
const NativeBadge = ({ children, variant = "default", className = "" }: { children: React.ReactNode, variant?: string, className?: string }) => {
const baseClasses = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium";
const variantClasses = {
default: "bg-blue-100 text-blue-800",
secondary: "bg-gray-100 text-gray-800",
destructive: "bg-red-100 text-red-800",
outline: "border border-gray-300 text-gray-700 bg-white"
};
return (
<span className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${className}`}>
{children}
</span>
);
};
// Button native
const NativeButton = ({ children, onClick, disabled = false, variant = "default", size = "default", className = "", title = "" }: {
children: React.ReactNode,
onClick?: () => void,
disabled?: boolean,
variant?: string,
size?: string,
className?: string,
title?: string
}) => {
const baseClasses = "inline-flex items-center justify-center font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantClasses = {
default: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
};
const sizeClasses = {
default: "px-4 py-2 text-sm",
sm: "px-2 py-1 text-xs"
};
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${sizeClasses[size as keyof typeof sizeClasses] || sizeClasses.default} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{children}
</button>
);
};
if (isLoading || suppliersLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header avec onglets */}
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
<FileText className="w-6 h-6 mr-3 text-blue-600" />
Rapprochement BL/Factures
</h2>
<p className="text-gray-600 mt-1">
Gestion des rapprochements manuels et automatiques
</p>
</div>
<div className="flex items-center space-x-4">
<NativeBadge variant="outline" className="text-sm border border-gray-300">
{manualReconciliationDeliveries.length} rapprochements manuels
</NativeBadge>
<NativeBadge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
{automaticReconciliationDeliveries.length} rapprochements automatiques
</NativeBadge>
</div>
</div>
{/* Onglets natifs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("manual")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "manual"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Edit className="w-4 h-4" />
<span>Rapprochement Manuel</span>
<NativeBadge variant="secondary" className="ml-2">
{manualReconciliationDeliveries.length}
</NativeBadge>
</button>
<button
onClick={() => setActiveTab("automatic")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "automatic"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Settings className="w-4 h-4" />
<span>Rapprochement Automatique</span>
<NativeBadge variant="secondary" className="ml-2">
{automaticReconciliationDeliveries.length}
</NativeBadge>
</button>
</nav>
</div>
</div>
{/* Filtre de recherche */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher par fournisseur, BL ou facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Contenu des onglets */}
{activeTab === "manual" && (
<div className="space-y-6">
{filteredManualDeliveries.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement manuel trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fournisseur</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant Fact.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentManualData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
delivery.reconciled ? 'bg-green-50' :
isDevalidated(delivery) ? 'bg-red-50' : ''
}`}
>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.blNumber || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceAmount ?
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
{delivery.reconciled ? (
<NativeBadge variant="default" className="bg-green-600 text-white">
Validé
</NativeBadge>
) : isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="outline">
En attente
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
)}
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
)}
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
{activeTab === "automatic" && (
<div className="space-y-6">
{/* Message d'information */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
<p className="text-sm text-blue-700 mt-1">
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
{permissions.canEdit('reconciliation') ? (
" Vous pouvez dévalider ces rapprochements si nécessaire."
) : (
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
)}
</p>
</div>
</div>
</div>
{filteredAutomaticDeliveries.length === 0 ? (
<div className="text-center py-12">
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement automatique trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
<NativeBadge variant="secondary" className="ml-2 text-xs">AUTO</NativeBadge>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Valid.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentAutomaticData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
}`}
>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900 font-medium">
{delivery.blNumber || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.reconciled && delivery.validatedAt ?
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
{isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="default" className="bg-green-600 text-white">
Auto-validé
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<>
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
</>
)}
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
</div>
);
}
+106
View File
@@ -24,6 +24,7 @@ import {
import type { Supplier, Contact, Group } from "@shared/schema";
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
const CAN_EDIT_SUPPLIER = ["admin", "directeur"];
export default function Contacts() {
const { user } = useAuthUnified();
@@ -32,6 +33,7 @@ export default function Contacts() {
const { stores, selectedStoreId } = useStore();
const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role);
const canEditSupplier = user?.role && CAN_EDIT_SUPPLIER.includes(user.role);
const isAdmin = user?.role === "admin";
// Pour les admins, permettre de choisir un magasin
@@ -41,17 +43,28 @@ export default function Contacts() {
const [supplierSearch, setSupplierSearch] = useState("");
const [contactSearch, setContactSearch] = useState("");
// Modal contacts libres
const [showModal, setShowModal] = useState(false);
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [formData, setFormData] = useState({
groupId: effectiveGroupId ?? 0,
name: "",
company: "",
role: "",
phone: "",
email: "",
notes: "",
});
// Modal coordonnées fournisseur
const [showSupplierModal, setShowSupplierModal] = useState(false);
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
const [supplierFormData, setSupplierFormData] = useState({
contact: "",
phone: "",
email: "",
});
const { data: suppliers = [] } = useQuery<Supplier[]>({
queryKey: ["/api/suppliers"],
});
@@ -105,11 +118,25 @@ export default function Contacts() {
},
});
const updateSupplierMutation = useMutation({
mutationFn: (data: any) => apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data),
onSuccess: () => {
toast({ title: "Succès", description: "Coordonnées fournisseur mises à jour" });
queryClient.invalidateQueries({ queryKey: ["/api/suppliers"] });
setShowSupplierModal(false);
setSelectedSupplier(null);
},
onError: () => {
toast({ title: "Erreur", description: "Impossible de modifier le fournisseur", variant: "destructive" });
},
});
const openCreate = () => {
setSelectedContact(null);
setFormData({
groupId: effectiveGroupId ?? 0,
name: "",
company: "",
role: "",
phone: "",
email: "",
@@ -123,6 +150,7 @@ export default function Contacts() {
setFormData({
groupId: contact.groupId,
name: contact.name,
company: (contact as any).company || "",
role: contact.role || "",
phone: contact.phone || "",
email: contact.email || "",
@@ -152,6 +180,16 @@ export default function Contacts() {
}
};
const openEditSupplier = (supplier: Supplier) => {
setSelectedSupplier(supplier);
setSupplierFormData({
contact: supplier.contact || "",
phone: supplier.phone || "",
email: (supplier as any).email || "",
});
setShowSupplierModal(true);
};
const filteredSuppliers = suppliers.filter((s) =>
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
@@ -260,6 +298,11 @@ export default function Contacts() {
)}
</div>
</div>
{canEditSupplier && (
<Button variant="ghost" size="sm" onClick={() => openEditSupplier(supplier)}>
<Edit className="w-4 h-4" />
</Button>
)}
</div>
<div className="mt-3 space-y-1 pl-12">
{supplier.phone && (
@@ -344,6 +387,9 @@ export default function Contacts() {
</div>
<div>
<p className="font-semibold text-gray-900">{contact.name}</p>
{(contact as any).company && (
<p className="text-sm font-medium text-gray-700">{(contact as any).company}</p>
)}
{contact.role && (
<p className="text-sm text-gray-500">{contact.role}</p>
)}
@@ -445,6 +491,16 @@ export default function Contacts() {
/>
</div>
<div>
<Label htmlFor="c-company">Entreprise</Label>
<Input
id="c-company"
value={formData.company}
onChange={(e) => setFormData((p) => ({ ...p, company: e.target.value }))}
placeholder="Nom de l'entreprise"
/>
</div>
<div>
<Label htmlFor="c-role">Fonction / Rôle</Label>
<Input
@@ -505,6 +561,56 @@ export default function Contacts() {
</form>
</DialogContent>
</Dialog>
{/* Modal coordonnées fournisseur */}
<Dialog open={showSupplierModal} onOpenChange={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Coordonnées — {selectedSupplier?.name}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => { e.preventDefault(); updateSupplierMutation.mutate(supplierFormData); }}
className="space-y-4"
>
<div>
<Label htmlFor="s-contact">Nom du contact</Label>
<Input
id="s-contact"
value={supplierFormData.contact}
onChange={(e) => setSupplierFormData((p) => ({ ...p, contact: e.target.value }))}
placeholder="Nom du contact chez le fournisseur"
/>
</div>
<div>
<Label htmlFor="s-phone">Téléphone</Label>
<Input
id="s-phone"
value={supplierFormData.phone}
onChange={(e) => setSupplierFormData((p) => ({ ...p, phone: e.target.value }))}
placeholder="Numéro de téléphone"
/>
</div>
<div>
<Label htmlFor="s-email">Email</Label>
<Input
id="s-email"
type="email"
value={supplierFormData.email}
onChange={(e) => setSupplierFormData((p) => ({ ...p, email: e.target.value }))}
placeholder="Adresse email"
/>
</div>
<div className="flex items-center space-x-3 pt-2">
<Button type="button" variant="outline" onClick={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
Annuler
</Button>
<Button type="submit" disabled={updateSupplierMutation.isPending} className="bg-primary hover:bg-blue-700">
{updateSupplierMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+10 -10
View File
@@ -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;
+335 -133
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast";
@@ -18,7 +19,7 @@ import {
Truck,
Palette
} from "lucide-react";
import type { Group, NocodbConfig } from "@shared/schema";
import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema";
const colorOptions = [
{ value: '#1976D2', label: 'Bleu' },
@@ -33,6 +34,38 @@ const colorOptions = [
{ value: '#00796B', label: 'Sarcelle' },
];
// Taille maximale du logo magasin : il est encodé en base64 dans la fiche
// et joint à chaque mail, un fichier lourd alourdirait tous les envois.
const MAX_LOGO_KB = 200;
// Valeurs par défaut du formulaire magasin (création et réinitialisations)
const EMPTY_GROUP_FORM = {
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
// Coordonnées reprises dans la signature des mails fournisseurs
address: "",
phone: "",
logo: "",
// Configuration SMTP propre au magasin
smtpEnabled: false,
smtpHost: "",
smtpPort: "",
smtpSecure: false,
smtpUser: "",
smtpPassword: "",
smtpSenderEmail: "",
smtpSenderName: "",
};
export default function Groups() {
const { user } = useAuthUnified();
const { toast } = useToast();
@@ -42,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,23 +87,11 @@ 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
});
const { data: orders = [] } = useQuery({
queryKey: ['/api/orders'],
});
const { data: deliveries = [] } = useQuery({
queryKey: ['/api/deliveries'],
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
// rapatrier l'historique complet des commandes et des livraisons.
const { data: groupStats = [] } = useQuery<EntityActivityStats[]>({
queryKey: ['/api/stats/by-group'],
});
const createMutation = useMutation({
@@ -99,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)) {
@@ -145,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)) {
@@ -179,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");
@@ -214,59 +246,20 @@ export default function Groups() {
group.name.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
console.log('🏪 Groups Debug:', {
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
groupsData: groups?.slice(0, 2),
ordersData: orders?.slice(0, 2),
deliveriesData: deliveries?.slice(0, 2)
});
const getGroupStats = (groupId: number) => {
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
const match = order.groupId === groupId;
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
return match;
}) : [];
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
const match = delivery.groupId === groupId;
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
return match;
}) : [];
console.log(`🏪 Group ${groupId} stats:`, {
groupOrders: groupOrders.length,
groupDeliveries: groupDeliveries.length,
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
ordersSample: groupOrders.slice(0, 2),
deliveriesSample: groupDeliveries.slice(0, 2),
allOrdersCount: orders?.length,
allDeliveriesCount: deliveries?.length
});
const stats = Array.isArray(groupStats)
? groupStats.find(s => s.id === groupId)
: undefined;
return {
orders: groupOrders.length,
deliveries: groupDeliveries.length,
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
orders: stats?.orders ?? 0,
deliveries: stats?.deliveries ?? 0,
delivered: stats?.delivered ?? 0,
};
};
const handleCreate = () => {
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
setShowCreateModal(true);
};
@@ -284,6 +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);
};
@@ -318,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 {
@@ -330,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 }));
};
@@ -413,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>
@@ -448,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>
@@ -492,16 +503,9 @@ export default function Groups() {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableId: "",
nocodbTableName: "",
invoiceColumnName: "Ref Facture",
});
setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" });
}}>
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto" aria-describedby="group-modal-description">
<DialogHeader>
<DialogTitle>
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
@@ -512,6 +516,9 @@ export default function Groups() {
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 items-start">
{/* Colonne gauche : identité + configuration NocoDB */}
<div className="space-y-4">
<div>
<Label htmlFor="name">Nom du groupe *</Label>
<Input
@@ -712,27 +719,222 @@ export default function Groups() {
)}
</div>
<div className="flex items-center space-x-3 pt-4">
<Button
type="button"
variant="outline"
</div>
{/* Colonne droite : coordonnées + envoi de mails */}
<div className="space-y-4">
{/* Section Coordonnées du magasin (signature des mails) */}
<div className="border-t md:border-t-0 pt-4 md:pt-0 space-y-4">
<div className="flex items-center space-x-2 mb-1">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
</div>
<p className="text-sm text-gray-600">
Reprises dans la signature des mails envoyés aux fournisseurs.
</p>
<div>
<Label htmlFor="address">Adresse</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder={"12 rue des Fleurs\n54000 Nancy"}
rows={2}
/>
</div>
<div>
<Label htmlFor="phone">Téléphone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="03 83 00 00 00"
/>
</div>
<div>
<Label htmlFor="logo">Logo</Label>
<div className="flex items-center space-x-3 mt-1">
{formData.logo ? (
<img
src={formData.logo}
alt="Logo du magasin"
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
/>
) : (
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
Aucun logo
</div>
)}
<div className="space-y-1">
<Input
id="logo"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
className="text-xs"
/>
{formData.logo && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-red-600 hover:text-red-700"
onClick={() => handleChange('logo', '')}
>
Retirer le logo
</Button>
)}
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
</p>
</div>
</div>
{/* Section Configuration SMTP du magasin */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-purple-500 rounded"></div>
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpEnabled}
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Activé</span>
</label>
</div>
<p className="text-sm text-gray-600">
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
</p>
{formData.smtpEnabled && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
<Input
id="smtpHost"
value={formData.smtpHost}
onChange={(e) => handleChange('smtpHost', e.target.value)}
placeholder="smtp.office365.com"
/>
</div>
<div>
<Label htmlFor="smtpPort">Port *</Label>
<Input
id="smtpPort"
type="number"
value={formData.smtpPort}
onChange={(e) => handleChange('smtpPort', e.target.value)}
placeholder="587"
/>
</div>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpSecure}
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpUser">Identifiant</Label>
<Input
id="smtpUser"
value={formData.smtpUser}
onChange={(e) => handleChange('smtpUser', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="smtpPassword">Mot de passe</Label>
<Input
id="smtpPassword"
type="password"
value={formData.smtpPassword}
onChange={(e) => handleChange('smtpPassword', e.target.value)}
placeholder={
selectedGroup && (selectedGroup as any).smtpPasswordSet
? "Inchangé"
: "Mot de passe"
}
autoComplete="new-password"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
<Input
id="smtpSenderEmail"
type="email"
value={formData.smtpSenderEmail}
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
/>
</div>
<div>
<Label htmlFor="smtpSenderName">Nom affiché</Label>
<Input
id="smtpSenderName"
value={formData.smtpSenderName}
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
placeholder={formData.name || "LaFoir'Fouille Nancy"}
/>
</div>
</div>
{selectedGroup && (
<div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={testSmtpMutation.isPending}
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
>
{testSmtpMutation.isPending
? "Test en cours..."
: "✉️ Tester la connexion SMTP"}
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
}}
>
Annuler
+1 -1
View File
@@ -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);
};
+12 -14
View File
@@ -29,7 +29,7 @@ import {
CheckCircle,
Clock
} from "lucide-react";
import type { Supplier } from "@shared/schema";
import type { EntityActivityStats, Supplier } from "@shared/schema";
export default function Suppliers() {
const { user } = useAuthUnified();
@@ -56,12 +56,10 @@ export default function Suppliers() {
queryKey: ['/api/suppliers'],
});
const { data: orders = [] } = useQuery({
queryKey: ['/api/orders'],
});
const { data: deliveries = [] } = useQuery({
queryKey: ['/api/deliveries'],
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
// rapatrier l'historique complet des commandes et des livraisons.
const { data: supplierStats = [] } = useQuery<EntityActivityStats[]>({
queryKey: ['/api/stats/by-supplier'],
});
const createMutation = useMutation({
@@ -280,14 +278,14 @@ export default function Suppliers() {
);
const getSupplierStats = (supplierId: number) => {
const supplierOrders = orders.filter((order: any) => order.supplierId === supplierId);
const supplierDeliveries = deliveries.filter((delivery: any) => delivery.supplierId === supplierId);
const deliveredCount = supplierDeliveries.filter((delivery: any) => delivery.status === 'delivered').length;
const stats = Array.isArray(supplierStats)
? supplierStats.find(s => s.id === supplierId)
: undefined;
return {
orders: supplierOrders.length,
deliveries: supplierDeliveries.length,
delivered: deliveredCount,
orders: stats?.orders ?? 0,
deliveries: stats?.deliveries ?? 0,
delivered: stats?.delivered ?? 0,
};
};
-568
View File
@@ -1,568 +0,0 @@
import { useState, useEffect } from "react";
// Interface pour les tâches
interface Task {
id: number;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
status: 'pending' | 'completed';
assignedTo: string;
startDate?: string | null;
dueDate?: string | null;
createdAt?: string;
groupId?: number;
createdBy?: string;
}
export default function TasksListSimple() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreateForm, setShowCreateForm] = useState(false);
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
} else {
console.error('Erreur lors du chargement des tâches:', response.status);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Créer une tâche
const createTask = async (formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
groupId: 1,
createdBy: 'admin',
};
console.log('📤 Creating task:', taskData);
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Create Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche créée avec succès");
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la création:', error);
alert("Erreur lors de la création");
return false;
}
};
// Modifier une tâche
const updateTask = async (id: number, formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
// Préparer les données exactement comme le serveur les attend
const taskData: Partial<Task> = {
title: title.trim(),
description: description?.trim() || "",
priority: (priority as Task['priority']) || "medium",
status: (status as Task['status']) || "pending",
assignedTo: assignedTo.trim(),
};
// Gestion spéciale des dates pour éviter les erreurs de format
if (startDate && startDate.trim() !== '') {
taskData.startDate = startDate;
} else {
taskData.startDate = null;
}
if (dueDate && dueDate.trim() !== '') {
taskData.dueDate = dueDate;
} else {
taskData.dueDate = null;
}
console.log('📤 Updating task:', { id, taskData });
const response = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Update Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Update Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche modifiée avec succès");
setEditingId(null);
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la modification:', error);
alert("Erreur lors de la modification");
return false;
}
};
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = !searchTerm || task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Formulaire inline
const TaskForm = ({ task, onSubmit, onCancel }: {
task?: Task;
onSubmit: (formData: FormData) => Promise<boolean>;
onCancel: () => void;
}) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const success = await onSubmit(formData);
if (success && !task) {
(e.target as HTMLFormElement).reset();
}
};
return (
<tr style={{ backgroundColor: '#f9fafb' }}>
<td colSpan={6} style={{ padding: '16px' }}>
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Description
</label>
<input
name="description"
type="text"
defaultValue={task?.description || ""}
placeholder="Description (optionnel)"
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Priorité
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
📅 Date de début
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
⏰ Date d'échéance
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '6px 12px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</td>
</tr>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '8px 16px',
backgroundColor: showCreateForm ? '#dc2626' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
{showCreateForm ? '❌ Annuler' : '➕ Nouvelle Tâche'}
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Tableau des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ backgroundColor: '#f9fafb' }}>
<tr>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Titre</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Priorité</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Statut</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Assigné à</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Dates</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Actions</th>
</tr>
</thead>
<tbody>
{showCreateForm && (
<TaskForm
onSubmit={createTask}
onCancel={() => setShowCreateForm(false)}
/>
)}
{filteredTasks.length === 0 ? (
<tr>
<td colSpan={6} style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
Aucune tâche trouvée
</td>
</tr>
) : (
filteredTasks.map((task) => (
editingId === task.id ? (
<TaskForm
key={task.id}
task={task}
onSubmit={(formData) => updateTask(task.id, formData)}
onCancel={() => setEditingId(null)}
/>
) : (
<tr key={task.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px 16px' }}>
<div>
<div style={{
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</div>
{task.description && (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
{task.description}
</div>
)}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.priority === 'high' ? '#fee2e2' : task.priority === 'medium' ? '#fef3c7' : '#d1fae5',
color: task.priority === 'high' ? '#dc2626' : task.priority === 'medium' ? '#d97706' : '#059669'
}}>
{task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.status === 'completed' ? '#d1fae5' : '#fef3c7',
color: task.status === 'completed' ? '#059669' : '#d97706'
}}>
{task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#6b7280' }}>
{task.assignedTo}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#6b7280' }}>
{task.startDate && (
<div>📅 {new Date(task.startDate).toLocaleDateString('fr-FR')}</div>
)}
{task.dueDate && (
<div>⏰ {new Date(task.dueDate).toLocaleDateString('fr-FR')}</div>
)}
</td>
<td style={{ padding: '12px 16px' }}>
<button
onClick={() => setEditingId(task.id)}
style={{
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</td>
</tr>
)
))
)}
</tbody>
</table>
</div>
</div>
);
}
-546
View File
@@ -1,546 +0,0 @@
import { useState, useEffect } from "react";
// Version 100% autonome sans aucune dépendance externe
export default function TasksProductionSimple() {
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Composant formulaire inline
const TaskForm = ({ task, onClose }: any) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return;
}
const submitBtn = e.currentTarget.querySelector('button[type="submit"]') as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "...";
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
};
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
const method = task ? 'PUT' : 'POST';
if (!task) {
(taskData as any).groupId = 1;
(taskData as any).createdBy = 'admin';
}
console.log('📤 Sending request:', { url, method, taskData });
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ API Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert(task ? "Tâche modifiée avec succès" : "Tâche créée avec succès");
onClose();
loadTasks(); // Recharger les tâches
} catch (error) {
console.error('Erreur:', error);
alert("Erreur lors de l'opération");
submitBtn.disabled = false;
submitBtn.textContent = task ? "Modifier" : "Créer";
}
};
return (
<div style={{ padding: '24px', backgroundColor: 'white' }}>
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '16px', marginBottom: '24px' }}>
<p style={{ fontSize: '14px', color: '#6b7280', margin: '0' }}>
📅 Date de début = Quand la tâche devient visible<br/>
⏰ Date d'échéance = Quand la tâche doit être terminée
</p>
<button
type="button"
onClick={onClose}
style={{
float: 'right',
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Description
</label>
<textarea
name="description"
defaultValue={task?.description || ""}
placeholder="Description de la tâche (optionnel)"
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Priorité *
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
📅 Date de début <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
⏰ Date d'échéance <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne assignée"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</div>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '24px',
marginBottom: '24px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateModal(true)}
style={{
padding: '10px 20px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
➕ Nouvelle Tâche
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Liste des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
{filteredTasks.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
<p>Aucune tâche trouvée</p>
</div>
) : (
filteredTasks.map((task, index) => (
<div
key={task.id}
style={{
padding: '16px 24px',
borderBottom: index < filteredTasks.length - 1 ? '1px solid #e5e7eb' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</h3>
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
<span>
Priorité: {task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
<span>
Statut: {task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
<span>Assigné à: {task.assignedTo}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => {
setSelectedTask(task);
setShowEditModal(true);
}}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</div>
</div>
))
)}
</div>
{/* Modal de création */}
{showCreateModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Créer une nouvelle tâche
</h2>
</div>
<TaskForm onClose={() => setShowCreateModal(false)} />
</div>
</div>
)}
{/* Modal d'édition */}
{showEditModal && selectedTask && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Modifier la tâche
</h2>
</div>
<TaskForm
task={selectedTask}
onClose={() => {
setShowEditModal(false);
setSelectedTask(null);
}}
/>
</div>
</div>
)}
</div>
);
}
-504
View File
@@ -1,504 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
ListTodo,
Plus,
Search,
Circle,
Clock,
AlertTriangle,
CheckCircle,
Edit,
Trash2
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Task } from "@shared/schema";
type TaskWithRelations = Task & {
creator: { id: string; username: string; name: string; };
group: { id: number; name: string; color: string; };
};
export default function TasksSimplified() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
// États locaux
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = useState<string>("all");
// Modals et tâche sélectionnée
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
// Récupération des tâches
const { data: tasks = [], isLoading } = useQuery({
queryKey: ["/api/tasks", selectedStoreId],
queryFn: () => {
const params = new URLSearchParams();
if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
},
enabled: !!user,
});
// Récupération des magasins
const { data: stores = [] } = useQuery<any[]>({
queryKey: ["/api/groups"],
enabled: !!user,
});
// Filtrer les tâches
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
// Création d'une tâche
const handleCreateTask = async (taskData: any) => {
try {
const finalData = {
...taskData,
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
};
console.log('🔍 TasksSimplified - Creating task with data:', {
selectedStoreId,
storesLength: stores.length,
finalGroupId: finalData.groupId,
taskData: finalData
});
await apiRequest('/api/tasks', 'POST', finalData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowCreateModal(false);
toast({
title: "Succès",
description: "Tâche créée avec succès",
});
} catch (error) {
console.error('❌ TasksSimplified - Error creating task:', error);
toast({
title: "Erreur",
description: "Impossible de créer la tâche",
variant: "destructive",
});
}
};
// Modification d'une tâche
const handleUpdateTask = async (taskData: any) => {
if (!selectedTask) return;
try {
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowEditModal(false);
setSelectedTask(null);
toast({
title: "Succès",
description: "Tâche modifiée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de modifier la tâche",
variant: "destructive",
});
}
};
// Complétion d'une tâche
const handleCompleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche marquée comme terminée",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de compléter la tâche",
variant: "destructive",
});
}
};
// Suppression d'une tâche
const handleDeleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche supprimée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de supprimer la tâche",
variant: "destructive",
});
}
};
const handleEditTask = (task: TaskWithRelations) => {
setSelectedTask(task);
setShowEditModal(true);
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed": return "bg-green-100 text-green-800";
case "pending": return "bg-yellow-100 text-yellow-800";
default: return "bg-gray-100 text-gray-800";
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case "high": return "bg-red-100 text-red-800";
case "medium": return "bg-blue-100 text-blue-800";
case "low": return "bg-green-100 text-green-800";
default: return "bg-gray-100 text-gray-800";
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Chargement des tâches...</div>
</div>
);
}
if (!user) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<ListTodo className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
</div>
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Tâche
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
</DialogHeader>
<TaskForm onSubmit={handleCreateTask} />
</DialogContent>
</Dialog>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filtres</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher une tâche..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="min-w-[150px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
<SelectItem value="completed">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-[150px]">
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priorité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes priorités</SelectItem>
<SelectItem value="high">Haute</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="low">Basse</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Liste des tâches */}
<div className="space-y-4">
{filteredTasks.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center h-32">
<div className="text-center">
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucune tâche trouvée</p>
</div>
</CardContent>
</Card>
) : (
filteredTasks.map((task: TaskWithRelations) => (
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
<Badge className={getStatusColor(task.status)}>
{task.status === "completed" ? "Terminée" : "En attente"}
</Badge>
<Badge className={getPriorityColor(task.priority)}>
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
</Badge>
</div>
{task.description && (
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
)}
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<span className="font-medium">Assigné à:</span>
<span>{task.assignedTo || "Non assigné"}</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Magasin:</span>
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
{task.group.name}
</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Créé par:</span>
<span>{task.creator.name || task.creator.username}</span>
</div>
{task.dueDate && (
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{task.status !== "completed" && (
<Button
size="sm"
variant="outline"
onClick={() => handleCompleteTask(task.id)}
className="text-green-600 hover:text-green-700"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handleEditTask(task)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteTask(task.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Modal de modification */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier la tâche</DialogTitle>
</DialogHeader>
{selectedTask && (
<TaskForm
initialData={selectedTask}
onSubmit={handleUpdateTask}
isEditing={true}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
// Composant de formulaire simplifié
function TaskForm({ onSubmit, initialData, isEditing = false }: {
onSubmit: (data: any) => void;
initialData?: TaskWithRelations;
isEditing?: boolean;
}) {
const [title, setTitle] = useState(initialData?.title || "");
const [description, setDescription] = useState(initialData?.description || "");
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
const [priority, setPriority] = useState(initialData?.priority || "medium");
const [dueDate, setDueDate] = useState(
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
return;
}
const taskData = {
title: title.trim(),
description: description.trim() || undefined,
assignedTo: assignedTo?.trim() || "Non assigné",
priority,
startDate: null, // Toujours inclure startDate même si null
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
};
onSubmit(taskData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Titre de la tâche *
</label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de la tâche"
className="w-full p-2 border rounded-md"
rows={3}
/>
</div>
<div>
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
Assigné à <span className="text-xs text-gray-500">(optionnel)</span>
</label>
<Input
id="assignedTo"
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
placeholder="Nom de la personne responsable"
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Priorité
</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Basse</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="high">Haute</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
Date d'échéance
</label>
<Input
id="dueDate"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex justify-end space-x-2">
<Button type="submit">
{isEditing ? "Modifier" : "Créer"}
</Button>
</div>
</form>
);
}
+3 -3
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1758639574 connect.sid s%3A8_9iOHdXBiypBHfh9CFiGGT7-mgFDQ-9.MPmF2NrBG7ywTs2h9HX4yW1JVNQ5dbq%2FHoZpz8jRTpo
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1755861696 connect.sid s%3AI3ww2VSYnJCTbvhTLFfsKWucSCgLEXnH.Q%2FZB%2FqjpNIGDgcSjAp4O%2BHP03Qrmj71qIb8njdhnrK4
-43
View File
@@ -1,43 +0,0 @@
-- Script DEFINITIF pour créer la table webhook_bap_config en production
-- À exécuter directement sur votre PostgreSQL de production
-- ATTENTION : Remplacez YOUR_DATABASE par le nom de votre base
\c YOUR_DATABASE;
-- Créer la table webhook_bap_config
DROP TABLE IF EXISTS webhook_bap_config CASCADE;
CREATE TABLE webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insérer la configuration par défaut avec VOTRE URL
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) VALUES (
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook-test/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration pour envoi des fichiers BAP vers n8n',
true
);
-- Créer la table migrations si elle n'existe pas
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Marquer la migration comme appliquée
INSERT INTO migrations (filename) VALUES ('20250903141000_create_webhook_bap_config.sql')
ON CONFLICT (filename) DO NOTHING;
-- Vérifier que tout est créé
SELECT 'Table webhook_bap_config créée avec succès' as message;
SELECT * FROM webhook_bap_config;
SELECT 'Table migrations mise à jour' as message;
SELECT * FROM migrations WHERE filename LIKE '%webhook_bap%';
-26
View File
@@ -1,26 +0,0 @@
-- Script SQL simple pour créer UNIQUEMENT la table announcements en production
-- À exécuter directement dans le conteneur Docker
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT announcements_priority_check CHECK (priority IN ('normal', 'important', 'urgent')),
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Créer les index pour les performances
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Vérification
SELECT 'Table announcements créée avec succès' as resultat;
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'announcements' ORDER BY ordinal_position;
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
// Script de débogage pour tester les endpoints de production
import https from 'https';
const BASE_URL = 'https://logiflow.ffnancy.fr';
async function makeRequest(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const requestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Debug-Script/1.0',
...options.headers
}
};
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
resolve({
status: res.statusCode,
headers: res.headers,
data: data,
length: data.length
});
});
});
req.on('error', (error) => {
reject(error);
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
async function debugProduction() {
console.log('🔍 Testing production endpoints...\n');
try {
// Test 1: Health check
console.log('1. Health check:');
const health = await makeRequest('/api/health');
console.log(` Status: ${health.status}`);
console.log(` Response: ${health.data.substring(0, 100)}...\n`);
// Test 2: Default credentials check
console.log('2. Default credentials check:');
const defaultCreds = await makeRequest('/api/default-credentials-check');
console.log(` Status: ${defaultCreds.status}`);
console.log(` Response: ${defaultCreds.data}\n`);
// Test 3: Login attempts with different credentials
const credentialsList = [
{ username: 'admin', password: 'admin' },
{ username: 'administrator', password: 'administrator' },
{ username: 'lff', password: 'lff2024' },
{ username: 'logiflow', password: 'logiflow' },
{ username: 'root', password: 'root' }
];
let login = null;
let cookies = '';
for (const creds of credentialsList) {
console.log(`3. Login attempt with ${creds.username}:${creds.password}:`);
login = await makeRequest('/api/login', {
method: 'POST',
body: JSON.stringify(creds)
});
console.log(` Status: ${login.status}`);
console.log(` Response: ${login.data.substring(0, 200)}...\n`);
if (login.status === 200 && login.headers['set-cookie']) {
cookies = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
console.log(`✅ Successful login with ${creds.username}\n`);
break;
}
}
// Cookies already extracted above if login was successful
// Test 4: User info (with cookies)
if (cookies) {
console.log('4. User info (authenticated):');
const user = await makeRequest('/api/user', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${user.status}`);
console.log(` Response: ${user.data.substring(0, 200)}...\n`);
// Test 5: Tasks (with cookies)
console.log('5. Tasks list (authenticated):');
const tasks = await makeRequest('/api/tasks', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${tasks.status}`);
console.log(` Response: ${tasks.data.substring(0, 300)}...\n`);
// Test 6: Test task update (with cookies) - testing the problematic endpoint
console.log('6. Task update test (problematic endpoint):');
const taskUpdate = await makeRequest('/api/tasks/99', {
method: 'PUT',
headers: { 'Cookie': cookies },
body: JSON.stringify({
title: 'Test Debug',
description: 'Test de debug',
priority: 'medium',
status: 'pending',
assignedTo: 'admin',
startDate: '2025-08-20',
dueDate: null
})
});
console.log(` Status: ${taskUpdate.status}`);
console.log(` Response: ${taskUpdate.data}\n`);
} else {
console.log('❌ No cookies received from login - cannot test authenticated endpoints\n');
}
} catch (error) {
console.error('❌ Error during debug:', error.message);
}
}
debugProduction();
-77
View File
@@ -1,77 +0,0 @@
-- 🔍 DIAGNOSTIC PRODUCTION COMPLET - Module Avoirs
-- Copiez-collez ce script dans votre utilitaire SQL pour diagnostic précis
BEGIN;
-- SECTION 1: STRUCTURE DES TABLES
SELECT 'VÉRIFICATION STRUCTURE TABLES' AS section_title;
-- Table avoirs - structure exacte
SELECT 'STRUCTURE_AVOIRS' AS check_type, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'avoirs' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table suppliers - structure exacte
SELECT 'STRUCTURE_SUPPLIERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'suppliers' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table groups - structure exacte
SELECT 'STRUCTURE_GROUPS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'groups' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table users - structure exacte
SELECT 'STRUCTURE_USERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users' AND table_schema = 'public'
ORDER BY ordinal_position;
-- SECTION 2: CONTRAINTES
SELECT 'CONTRAINTES_AVOIRS' AS check_type, conname AS constraint_name, contype AS type,
confrelid::regclass AS referenced_table
FROM pg_constraint
WHERE conrelid = 'avoirs'::regclass;
-- SECTION 3: DONNÉES EXISTANTES
SELECT 'COMPTES_TABLES' AS check_type, 'suppliers' AS table_name, COUNT(*) AS count FROM suppliers
UNION ALL
SELECT 'COMPTES_TABLES', 'groups', COUNT(*) FROM groups
UNION ALL
SELECT 'COMPTES_TABLES', 'users', COUNT(*) FROM users
UNION ALL
SELECT 'COMPTES_TABLES', 'avoirs', COUNT(*) FROM avoirs;
-- SECTION 4: EXEMPLES DE DONNÉES (si elles existent)
-- Suppliers
SELECT 'EXEMPLES_SUPPLIERS' AS check_type, id, name FROM suppliers LIMIT 3;
-- Groups
SELECT 'EXEMPLES_GROUPS' AS check_type, id, name FROM groups LIMIT 3;
-- Users
SELECT 'EXEMPLES_USERS' AS check_type, id,
COALESCE(username, 'no_username') AS username,
role
FROM users LIMIT 3;
-- SECTION 5: TEST DE COMPATIBILITÉ
-- Test si on peut insérer avec des champs NULL
SELECT 'TEST_COMPATIBILITÉ' AS check_type,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'invoice_reference') = 'YES'
THEN 'invoice_reference_NULLABLE'
ELSE 'invoice_reference_NOT_NULL'
END AS invoice_ref_status,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'amount') = 'YES'
THEN 'amount_NULLABLE'
ELSE 'amount_NOT_NULL'
END AS amount_status;
ROLLBACK; -- Ne pas sauvegarder, c'est juste du diagnostic
-42
View File
@@ -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
-40
View File
@@ -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"
-34
View File
@@ -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;
-38
View File
@@ -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;
+32
View File
@@ -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
-- ============================================================================
-25
View File
@@ -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;
@@ -19,3 +19,6 @@ CREATE TABLE IF NOT EXISTS contacts (
-- Index pour recherche rapide par magasin
CREATE INDEX IF NOT EXISTS idx_contacts_group_id ON contacts(group_id);
-- Ajout du champ entreprise sur les contacts
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company VARCHAR(255);
@@ -0,0 +1,109 @@
-- Performance indexes
--
-- Contexte : aucune table (hors sessions) ne portait d'index en dehors des clés
-- primaires et contraintes UNIQUE. PostgreSQL n'indexe pas automatiquement les
-- clés étrangères, donc tous les filtres et jointures sur group_id, supplier_id,
-- order_id et les colonnes de date faisaient un scan séquentiel complet.
--
-- Ce fichier est exécuté par migrations/migrate.js, qui l'enveloppe dans une
-- transaction : CREATE INDEX CONCURRENTLY y est interdit. Les index sont donc
-- créés en mode bloquant (verrou en écriture sur la table, le temps de la
-- construction). Pour appliquer sans interruption sur une base en production
-- chargée, utiliser scripts/create-indexes-concurrently.js AVANT le déploiement :
-- les index existeront déjà et les IF NOT EXISTS ci-dessous seront sans effet.
-- === user_groups ===
-- Lu à chaque requête authentifiée via getUserWithGroups().
CREATE INDEX IF NOT EXISTS idx_user_groups_user_id ON user_groups (user_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group_id ON user_groups (group_id);
-- === orders ===
CREATE INDEX IF NOT EXISTS idx_orders_group_id ON orders (group_id);
CREATE INDEX IF NOT EXISTS idx_orders_supplier_id ON orders (supplier_id);
CREATE INDEX IF NOT EXISTS idx_orders_created_by ON orders (created_by);
CREATE INDEX IF NOT EXISTS idx_orders_planned_date ON orders (planned_date);
-- Requête du calendrier : filtre par magasin + plage de dates.
CREATE INDEX IF NOT EXISTS idx_orders_group_planned_date ON orders (group_id, planned_date);
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders (created_at DESC);
-- === deliveries ===
-- order_id est la clé de jointure du N+1 de getOrders() : le plus rentable.
CREATE INDEX IF NOT EXISTS idx_deliveries_order_id ON deliveries (order_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_group_id ON deliveries (group_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_supplier_id ON deliveries (supplier_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_created_by ON deliveries (created_by);
CREATE INDEX IF NOT EXISTS idx_deliveries_scheduled_date ON deliveries (scheduled_date);
CREATE INDEX IF NOT EXISTS idx_deliveries_group_scheduled_date ON deliveries (group_id, scheduled_date);
CREATE INDEX IF NOT EXISTS idx_deliveries_created_at ON deliveries (created_at DESC);
-- Rapprochement BL / factures.
CREATE INDEX IF NOT EXISTS idx_deliveries_invoice_reference ON deliveries (invoice_reference)
WHERE invoice_reference IS NOT NULL;
-- === reconciliation_comments ===
CREATE INDEX IF NOT EXISTS idx_reconciliation_comments_delivery_id ON reconciliation_comments (delivery_id);
CREATE INDEX IF NOT EXISTS idx_reconciliation_comments_group_id ON reconciliation_comments (group_id);
-- === contacts ===
CREATE INDEX IF NOT EXISTS idx_contacts_group_id ON contacts (group_id);
-- === customer_orders ===
CREATE INDEX IF NOT EXISTS idx_customer_orders_group_id ON customer_orders (group_id);
CREATE INDEX IF NOT EXISTS idx_customer_orders_supplier_id ON customer_orders (supplier_id);
CREATE INDEX IF NOT EXISTS idx_customer_orders_created_at ON customer_orders (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_customer_orders_group_status ON customer_orders (group_id, status);
-- === dlc_products ===
CREATE INDEX IF NOT EXISTS idx_dlc_products_group_id ON dlc_products (group_id);
CREATE INDEX IF NOT EXISTS idx_dlc_products_supplier_id ON dlc_products (supplier_id);
CREATE INDEX IF NOT EXISTS idx_dlc_products_expiry_date ON dlc_products (expiry_date);
CREATE INDEX IF NOT EXISTS idx_dlc_products_group_status ON dlc_products (group_id, status);
-- === tasks ===
CREATE INDEX IF NOT EXISTS idx_tasks_group_id ON tasks (group_id);
CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks (due_date);
CREATE INDEX IF NOT EXISTS idx_tasks_group_status ON tasks (group_id, status);
-- === avoirs ===
CREATE INDEX IF NOT EXISTS idx_avoirs_group_id ON avoirs (group_id);
CREATE INDEX IF NOT EXISTS idx_avoirs_supplier_id ON avoirs (supplier_id);
CREATE INDEX IF NOT EXISTS idx_avoirs_created_at ON avoirs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_avoirs_group_status ON avoirs (group_id, status);
-- === sav_tickets ===
CREATE INDEX IF NOT EXISTS idx_sav_tickets_group_id ON sav_tickets (group_id);
CREATE INDEX IF NOT EXISTS idx_sav_tickets_supplier_id ON sav_tickets (supplier_id);
CREATE INDEX IF NOT EXISTS idx_sav_tickets_created_at ON sav_tickets (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sav_tickets_group_status ON sav_tickets (group_id, status);
-- === invoice_verification_cache ===
-- expires_at sert la purge périodique (cleanExpiredCache).
CREATE INDEX IF NOT EXISTS idx_invoice_cache_expires_at ON invoice_verification_cache (expires_at);
CREATE INDEX IF NOT EXISTS idx_invoice_cache_group_id ON invoice_verification_cache (group_id);
-- === publicity_participations ===
-- La clé primaire (publicity_id, group_id) ne couvre pas les recherches par group_id seul.
CREATE INDEX IF NOT EXISTS idx_publicity_participations_group_id ON publicity_participations (group_id);
-- === publicities ===
CREATE INDEX IF NOT EXISTS idx_publicities_year ON publicities (year);
-- === dashboard_messages ===
CREATE INDEX IF NOT EXISTS idx_dashboard_messages_store_id ON dashboard_messages (store_id);
CREATE INDEX IF NOT EXISTS idx_dashboard_messages_created_at ON dashboard_messages (created_at DESC);
-- === weather_data ===
CREATE INDEX IF NOT EXISTS idx_weather_data_date_location ON weather_data (date, location);
-- Rafraîchit les statistiques du planificateur pour qu'il utilise réellement
-- les nouveaux index sans attendre le prochain passage d'autovacuum.
ANALYZE user_groups;
ANALYZE orders;
ANALYZE deliveries;
ANALYZE reconciliation_comments;
ANALYZE customer_orders;
ANALYZE dlc_products;
ANALYZE tasks;
ANALYZE avoirs;
ANALYZE sav_tickets;
ANALYZE invoice_verification_cache;
+94 -97
View File
@@ -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": {
+2 -1
View File
@@ -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",
-55
View File
@@ -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 };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

-33
View File
@@ -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();
-27
View File
@@ -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();
+132 -1
View File
@@ -111,4 +111,135 @@ else
echo "ℹ️ [AUTO-MIGRATE] Table webhook_bap_config existe déjà - aucune action nécessaire"
fi
echo "✅ [AUTO-MIGRATE] Migration terminée avec succès - webhook_bap_config incluse!"
# Index de performance
# Aucune table ne portait d'index en dehors des clés primaires et contraintes
# UNIQUE. PostgreSQL n'indexe pas les clés étrangères automatiquement : tous les
# filtres sur group_id / supplier_id / order_id et les colonnes de date
# faisaient un scan séquentiel complet, d'où la dégradation progressive à mesure
# que les tables grossissent.
#
# CREATE INDEX CONCURRENTLY ne verrouille pas la table en écriture, mais ne peut
# pas s'exécuter dans une transaction : d'où l'appel psql direct sans BEGIN.
echo "🔄 [AUTO-MIGRATE] Vérification des index de performance..."
if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM pg_indexes WHERE schemaname='public' AND indexname='idx_deliveries_order_id');" | grep -q "f"; then
echo "🔧 [AUTO-MIGRATE] Création des index de performance (sans interruption de service)..."
# Une création CONCURRENTLY interrompue laisse un index invalide, que
# CREATE INDEX IF NOT EXISTS considère ensuite comme déjà présent. On purge
# ces reliquats avant de (re)créer.
psql "$DATABASE_URL" << 'EOF'
DO $$
DECLARE idx RECORD;
BEGIN
FOR idx IN
SELECT c.relname
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS public.%I', idx.relname);
RAISE NOTICE 'Index invalide supprimé: %', idx.relname;
END LOOP;
END $$;
EOF
psql "$DATABASE_URL" << 'EOF'
-- user_groups : lu à chaque requête authentifiée via getUserWithGroups()
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_groups_user_id ON user_groups (user_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_groups_group_id ON user_groups (group_id);
-- orders
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_group_id ON orders (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_supplier_id ON orders (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_by ON orders (created_by);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_planned_date ON orders (planned_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_group_planned_date ON orders (group_id, planned_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at ON orders (created_at DESC);
-- deliveries : order_id est la clé de jointure la plus sollicitée
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_order_id ON deliveries (order_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_group_id ON deliveries (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_supplier_id ON deliveries (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_created_by ON deliveries (created_by);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_scheduled_date ON deliveries (scheduled_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_group_scheduled_date ON deliveries (group_id, scheduled_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_created_at ON deliveries (created_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_invoice_reference ON deliveries (invoice_reference) WHERE invoice_reference IS NOT NULL;
-- reconciliation_comments
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reconciliation_comments_delivery_id ON reconciliation_comments (delivery_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reconciliation_comments_group_id ON reconciliation_comments (group_id);
-- contacts
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_group_id ON contacts (group_id);
-- customer_orders
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_group_id ON customer_orders (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_supplier_id ON customer_orders (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_created_at ON customer_orders (created_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_group_status ON customer_orders (group_id, status);
-- dlc_products
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_group_id ON dlc_products (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_supplier_id ON dlc_products (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_expiry_date ON dlc_products (expiry_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_group_status ON dlc_products (group_id, status);
-- tasks
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_group_id ON tasks (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_due_date ON tasks (due_date);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_group_status ON tasks (group_id, status);
-- avoirs
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_group_id ON avoirs (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_supplier_id ON avoirs (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_created_at ON avoirs (created_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_group_status ON avoirs (group_id, status);
-- sav_tickets
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_group_id ON sav_tickets (group_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_supplier_id ON sav_tickets (supplier_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_created_at ON sav_tickets (created_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_group_status ON sav_tickets (group_id, status);
-- invoice_verification_cache : expires_at sert la purge périodique
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoice_cache_expires_at ON invoice_verification_cache (expires_at);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoice_cache_group_id ON invoice_verification_cache (group_id);
-- publicity_participations : la PK (publicity_id, group_id) ne couvre pas group_id seul
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publicity_participations_group_id ON publicity_participations (group_id);
-- publicities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publicities_year ON publicities (year);
-- dashboard_messages
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dashboard_messages_store_id ON dashboard_messages (store_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dashboard_messages_created_at ON dashboard_messages (created_at DESC);
-- weather_data
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_weather_data_date_location ON weather_data (date, location);
EOF
# Rafraîchit les statistiques du planificateur pour qu'il utilise réellement
# les nouveaux index sans attendre le prochain autovacuum.
psql "$DATABASE_URL" << 'EOF'
ANALYZE user_groups;
ANALYZE orders;
ANALYZE deliveries;
ANALYZE reconciliation_comments;
ANALYZE customer_orders;
ANALYZE dlc_products;
ANALYZE tasks;
ANALYZE avoirs;
ANALYZE sav_tickets;
ANALYZE invoice_verification_cache;
EOF
echo "✅ [AUTO-MIGRATE] Index de performance créés avec succès"
else
echo "ℹ️ [AUTO-MIGRATE] Index de performance déjà présents - aucune action nécessaire"
fi
echo "✅ [AUTO-MIGRATE] Migration terminée avec succès - index de performance inclus!"
+11 -28
View File
@@ -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;
}
}
+1 -1
View File
@@ -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();
+76
View File
@@ -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.'
);
}
}
+3 -3
View File
@@ -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
}
+169
View File
@@ -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();
}
}
+12 -5
View File
@@ -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' });
}
+4
View File
@@ -59,6 +59,10 @@ app.use((req, res, next) => {
const server = await registerRoutes(app);
// Tâches de maintenance périodiques (purge du cache factures expiré)
const { startMaintenanceJobs } = await import('./maintenance.js');
startMaintenanceJobs();
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
+2
View File
@@ -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;
+27
View File
@@ -0,0 +1,27 @@
import { storage } from "./storage.js";
// Purge périodique du cache de vérification des factures.
//
// La table invoice_verification_cache porte une colonne expires_at et
// storage.clearExpiredCache() sait la purger, mais rien ne l'appelait : les
// entrées expirées s'accumulaient indéfiniment et alourdissaient chaque lecture
// du cache. On planifie donc la purge ici.
const PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 heures
const STARTUP_DELAY_MS = 60 * 1000; // laisse le démarrage se terminer d'abord
async function purgeExpiredInvoiceCache(): Promise<void> {
try {
await storage.clearExpiredCache();
console.log("🧹 [MAINTENANCE] Cache de vérification des factures purgé");
} catch (error) {
// Une purge ratée ne doit jamais interrompre le service.
console.error("❌ [MAINTENANCE] Échec de la purge du cache factures:", error);
}
}
export function startMaintenanceJobs(): void {
setTimeout(purgeExpiredInvoiceCache, STARTUP_DELAY_MS);
setInterval(purgeExpiredInvoiceCache, PURGE_INTERVAL_MS);
console.log("✅ [MAINTENANCE] Purge du cache factures planifiée (toutes les 6h)");
}
+75 -1
View File
@@ -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:', {
+41 -1
View File
@@ -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);
`
}
];
+303 -18
View File
@@ -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'
});
@@ -1389,6 +1460,66 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Statistiques agrégées commandes/livraisons.
//
// Les pages Magasins et Fournisseurs n'affichent que des comptages. Elles
// chargeaient tout l'historique via /api/orders et /api/deliveries pour ne
// faire que des .filter().length côté navigateur : le comptage est fait en
// base et seules les valeurs agrégées transitent.
const resolveStatsGroupIds = (user: any, storeId?: string): number[] | undefined | null => {
const userGroupIds: number[] = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (user.role === 'admin') {
return storeId ? [parseInt(storeId)] : undefined; // undefined = tous les magasins
}
if (storeId) {
const requestedStoreId = parseInt(storeId);
// null = accès refusé au magasin demandé
return userGroupIds.includes(requestedStoreId) ? [requestedStoreId] : null;
}
return userGroupIds.length > 0 ? userGroupIds : null;
};
app.get('/api/stats/by-group', 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 groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined);
if (groupIds === null) {
return res.json([]);
}
res.json(await storage.getOrderDeliveryStatsByGroup(groupIds));
} catch (error) {
console.error("Error fetching group stats:", error);
res.status(500).json({ message: "Failed to fetch group stats" });
}
});
app.get('/api/stats/by-supplier', 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 groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined);
if (groupIds === null) {
return res.json([]);
}
res.json(await storage.getOrderDeliveryStatsBySupplier(groupIds));
} catch (error) {
console.error("Error fetching supplier stats:", error);
res.status(500).json({ message: "Failed to fetch supplier stats" });
}
});
app.get('/api/orders/:id', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
@@ -1422,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
});
@@ -1469,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'
});
@@ -1571,11 +1702,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
const fixedOrders = [];
for (const order of orders) {
// Get deliveries for this order separately since OrderWithRelations doesn't include deliveries
const deliveries = await storage.getDeliveries();
const orderDeliveries = deliveries.filter(d => d.orderId === order.id);
// getOrders() renvoie déjà les livraisons de chaque commande : les
// recharger ici relisait toute la table à chaque itération.
const orderDeliveries = order.deliveries ?? [];
if (orderDeliveries && orderDeliveries.length > 0) {
if (orderDeliveries.length > 0) {
const hasDeliveredDeliveries = orderDeliveries.some((d: any) => d.status === 'delivered');
if (hasDeliveredDeliveries && order.status !== 'delivered') {
@@ -2194,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);
@@ -2419,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" });
}
@@ -4460,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 });
}
});
@@ -4499,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 });
}
});
@@ -5289,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
@@ -5448,6 +5723,16 @@ RÉSUMÉ DU SCAN
}
}
// Repli : si l'API historique a échoué (quota épuisé, panne, plan sans
// accès à l'historique), reprendre la ligne en cache la plus proche de
// la date cible plutôt que de faire disparaître la comparaison N-1
if (!previousYearData) {
previousYearData = await storage.getNearestWeatherData(previousYearDate, false, 7);
if (previousYearData) {
console.log(`🌤️ [FALLBACK] Using nearest cached previous-year data (${previousYearData.date}) for target ${previousYearDate}`);
}
}
// Toujours retourner une réponse même si seule l'année actuelle est disponible
const response = {
currentYear: currentYearData ? {
@@ -5950,7 +6235,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;
}
@@ -5981,7 +6266,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;
}
@@ -6010,7 +6295,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;
}
@@ -6063,7 +6348,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;
}
+43
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
+471 -265
View File
File diff suppressed because it is too large. Load diff
+59
View File
@@ -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(),
});
@@ -95,6 +108,7 @@ export const contacts = pgTable("contacts", {
id: serial("id").primaryKey(),
groupId: integer("group_id").notNull(),
name: varchar("name").notNull(),
company: varchar("company"),
role: varchar("role"),
phone: varchar("phone"),
email: varchar("email"),
@@ -553,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({
@@ -769,6 +794,15 @@ export type OrderWithRelations = Order & {
deliveries?: DeliveryWithRelations[];
};
// Comptages commandes/livraisons par magasin ou par fournisseur, agrégés en base.
// Évite de rapatrier tout l'historique côté client pour n'en compter que les lignes.
export type EntityActivityStats = {
id: number;
orders: number;
deliveries: number;
delivered: number;
};
export type DeliveryWithRelations = Delivery & {
supplier: Supplier;
group: Group;
@@ -951,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"),
@@ -971,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>;
+180
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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&nbsp;:</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&nbsp;:</p>
<ul>${details}</ul>
<p>Vous en remerciant par avance.</p>
<p style="margin-bottom:12px;">Cordialement,</p>
${signatureBlock}
</div>`;
}
-35
View File
@@ -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é!"
-124
View File
@@ -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>
+1 -3
View File
@@ -34,9 +34,7 @@
"vite/client",
"express",
"react",
"react-dom",
"node-fetch",
"date-fns"
"react-dom"
],
"paths": {
"@/*": [
-73
View File
@@ -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 -1
View File
@@ -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: {