diff --git a/ansible/group_vars/all/vars.yml b/ansible/group_vars/all/vars.yml index d840b373..b135cd81 100644 --- a/ansible/group_vars/all/vars.yml +++ b/ansible/group_vars/all/vars.yml @@ -20,7 +20,7 @@ backend_dir: "/home/ubuntu/backend" backend_binary: "/home/ubuntu/backend/main" uploads_dir: "/home/ubuntu/backend/uploads" frontend_port: 5173 -domain_name: "uber-stup.club" +domain_name: "mln-uber.club" # Utilisateurs et permissions user_web: "www-data" # Utilisateur pour les applications web user_deploy: "ubuntu" # Utilisateur pour le déploiement diff --git a/ansible/inventory/host.ini b/ansible/inventory/host.ini index 919cad91..5fbd8c65 100644 --- a/ansible/inventory/host.ini +++ b/ansible/inventory/host.ini @@ -1,5 +1,9 @@ [all] uber-stup ansible_host=5.252.20.129 ansible_user=root ansible_ssh_pass=rL9lY6YkcDQmfRuZ3Z +uber-stup-web ansible_host=185.234.9.102 ansible_user=root ansible_ssh_pass=hTASFYOydY46haeO3J [uber-stup] uber-stup ansible_host=5.252.20.129 ansible_user=root ansible_ssh_pass=rL9lY6YkcDQmfRuZ3Z + +[uber-stup-web] +uber-stup-web ansible_host=185.234.9.102 ansible_user=root ansible_ssh_pass=hTASFYOydY46haeO3J diff --git a/ansible/playbook-backend.yml b/ansible/playbook-backend.yml index 50dd5911..a3113b7c 100644 --- a/ansible/playbook-backend.yml +++ b/ansible/playbook-backend.yml @@ -1,6 +1,6 @@ --- - name: Installation et configuration du frontend et backend - hosts: all + hosts: uber-stup become: true gather_facts: true @@ -94,7 +94,6 @@ owner: "{{ user_deploy }}" group: "{{ user_deploy }}" mode: "0755" - tags: backend - name: Synchroniser le backend Go ansible.builtin.synchronize: @@ -111,6 +110,20 @@ recursive: yes tags: backend + - name: Fix backend ownership after rsync (rsync tourne en root) + ansible.builtin.file: + path: "{{ backend_dir }}" + owner: "{{ user_deploy }}" + group: "{{ user_deploy }}" + recurse: yes + tags: backend + + - name: Remove old binary if owned by another user + ansible.builtin.file: + path: "{{ backend_dir }}/main" + state: absent + tags: backend + - name: Compile backend as deploy user become_user: "{{ user_deploy }}" ansible.builtin.shell: | diff --git a/ansible/playbook-fail2ban.yml b/ansible/playbook-fail2ban.yml index 90a087f6..78056ec0 100644 --- a/ansible/playbook-fail2ban.yml +++ b/ansible/playbook-fail2ban.yml @@ -3,7 +3,7 @@ # Fail2Ban Installation et configuration # ============================================ - name: Installation et configuration de Fail2Ban - hosts: uber-stup + hosts: uber-stup-web become: true gather_facts: true diff --git a/ansible/playbook-frontend.yml b/ansible/playbook-frontend.yml new file mode 100644 index 00000000..7f575e35 --- /dev/null +++ b/ansible/playbook-frontend.yml @@ -0,0 +1,260 @@ +- name: Déploiement complet Frontend + hosts: uber-stup-web + gather_facts: true + become: true + vars: + domain_name: mln-uber.club + frontend_dir: /home/ubuntu/frontend + frontend_port: 5173 + tasks: + - name: Update and upgrade + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + upgrade: dist + + - name: Install basic dependencies + ansible.builtin.apt: + name: + - curl + - nginx + - tar + - build-essential + - certbot + - python3-certbot-nginx + state: present + - name: Créer l'utilisateur de déploiement s'il n'existe pas + ansible.builtin.user: + name: "{{ user_deploy }}" + shell: /bin/bash + create_home: yes + state: present + + - name: Ensure /home/{{ user_deploy }} exists with correct permissions + ansible.builtin.file: + path: "/home/{{ user_deploy }}" + state: directory + owner: "{{ user_deploy }}" + group: "{{ user_deploy }}" + mode: "0755" + + - name: Setup Node.js 20 repository + ansible.builtin.shell: | + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + args: + executable: /bin/bash + creates: /etc/apt/sources.list.d/nodesource.list + + - name: Install Node.js 20 + ansible.builtin.apt: + name: nodejs + state: present + update_cache: yes + dpkg_options: "force-overwrite" + + - name: Verify Node.js and npm versions + ansible.builtin.shell: | + node -v + npm -v + register: versions_check + changed_when: false + + - name: Show versions + ansible.builtin.debug: + msg: "{{ versions_check.stdout_lines }}" + + - name: Create folder for frontend + ansible.builtin.file: + path: "{{ frontend_dir }}" + state: directory + owner: "{{ user_deploy }}" + group: "{{ user_deploy }}" + mode: "0755" + tags: frontend + + - name: Synchroniser le frontend (excluant node_modules et .git) + become_user: "{{ user_deploy }}" + ansible.builtin.synchronize: + src: ../frontend-prep/ + dest: "{{ frontend_dir }}/" + rsync_opts: + - "--exclude=node_modules" + - "--exclude=.git" + - "--exclude=.gitignore" + - "--exclude=build" + delete: no + recursive: yes + perms: yes + owner: yes + rsync_path: rsync + tags: frontend + + - name: Install npm dependencies + become_user: "{{ user_deploy }}" + ansible.builtin.command: npm install + args: + chdir: "{{ frontend_dir }}" + tags: frontend + + - name: Build frontend + become_user: "{{ user_deploy }}" + ansible.builtin.command: npm run build + args: + chdir: "{{ frontend_dir }}" + tags: frontend + + - name: Install serve globally + ansible.builtin.command: npm install -g serve + tags: frontend + + - name: Création du fichier systemd pour le frontend + ansible.builtin.copy: + dest: /etc/systemd/system/frontend.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Frontend React (serve) + After=network.target + + [Service] + User={{ user_deploy }} + WorkingDirectory={{ frontend_dir }} + ExecStart=/usr/bin/npx serve -s build -l {{ frontend_port }} + Restart=always + RestartSec=5 + Environment="NODE_ENV=production" + Environment="VITE_TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB" + [Install] + WantedBy=multi-user.target + notify: Reload systemd + tags: frontend + + - name: Enable and start frontend service + ansible.builtin.systemd: + name: frontend + state: started + enabled: yes + daemon_reload: yes + tags: frontend + + - name: Configuration UFW + ansible.builtin.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + loop: + - "22" + - "80" + - "443" + + - name: Activation du firewall + ansible.builtin.ufw: + state: enabled + + - name: Vérifier si un certificat existe déjà + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem" + register: cert_file + tags: [certbot] + + - name: Déployer la configuration Nginx HTTP + ansible.builtin.template: + src: templates/nginx-frontend.conf.j2 + dest: /etc/nginx/sites-available/frontend + vars: + ssl_enabled: false + tags: [certbot] + + - name: Activation du site Nginx + ansible.builtin.file: + src: /etc/nginx/sites-available/frontend + dest: /etc/nginx/sites-enabled/frontend + state: link + force: yes + tags: [certbot] + + - name: Suppression du site par défaut + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + tags: [certbot] + + - name: Test de la configuration Nginx + ansible.builtin.command: nginx -t + changed_when: false + tags: [certbot] + + - name: Redémarrage de Nginx + ansible.builtin.systemd: + name: nginx + state: restarted + enabled: yes + tags: [certbot] + + # ============================================================ + # Certificat SSL Let's Encrypt + # ============================================================ + + - name: Générer le certificat SSL avec Certbot + ansible.builtin.command: > + certbot certonly --nginx + -d {{ domain_name }} + --non-interactive + --agree-tos + --email admin@{{ domain_name }} + when: not cert_file.stat.exists + tags: [certbot] + + # ============================================================ + # Nginx - reconfiguration HTTPS après certificat + # ============================================================ + - name: Vérifier la présence du certificat + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem" + register: cert_file_after + + - name: Déployer la configuration Nginx HTTPS + ansible.builtin.template: + src: templates/nginx-frontend.conf.j2 + dest: /etc/nginx/sites-available/frontend + vars: + ssl_enabled: true + when: cert_file_after.stat.exists + notify: Restart nginx + tags: [nginx] + + - name: Test de la configuration Nginx finale + ansible.builtin.command: nginx -t + changed_when: false + tags: [nginx] + + - name: Redémarrage de Nginx avec SSL + ansible.builtin.systemd: + name: nginx + state: restarted + when: cert_file_after.stat.exists + tags: [nginx] + + - name: Vérifier le renouvellement automatique + ansible.builtin.command: certbot renew --dry-run + register: certbot_renew + changed_when: false + failed_when: false + tags: [certbot] + + - name: Afficher le statut du renouvellement + ansible.builtin.debug: + msg: "{{ certbot_renew.stdout_lines }}" + tags: [certbot] + + handlers: + - name: Reload systemd + ansible.builtin.systemd: + daemon_reload: yes + + - name: Restart nginx + ansible.builtin.systemd: + name: nginx + state: restarted diff --git a/ansible/templates/nginx-frontend.conf.j2 b/ansible/templates/nginx-frontend.conf.j2 new file mode 100644 index 00000000..b8c2308f --- /dev/null +++ b/ansible/templates/nginx-frontend.conf.j2 @@ -0,0 +1,191 @@ +# ========================================================= +# Request ID for correlation +# ========================================================= +map $http_x_request_id $req_id { + default $http_x_request_id; + "" $request_id; +} + +# ========================================================= +# Backend distant +# ========================================================= +upstream backend { + server uber-stup.club:443; + keepalive 32; +} + +{% if ssl_enabled %} +# ========================================================= +# HTTP → HTTPS redirect + ACME challenge +# ========================================================= +server { + listen 80; + listen [::]:80; + server_name {{ domain_name }}; + + # Certbot renouvellement automatique + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ========================================================= +# HTTPS Server +# ========================================================= +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name {{ domain_name }}; + + # ========================================================= + # SSL/TLS - Let's Encrypt + # ========================================================= + ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/{{ domain_name }}/chain.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + ssl_stapling on; + ssl_stapling_verify on; + resolver 1.1.1.1 8.8.8.8 valid=300s; + resolver_timeout 5s; + +{% else %} +# ========================================================= +# HTTP Server (phase pré-certificat) +# ========================================================= +server { + listen 80; + listen [::]:80; + server_name {{ domain_name }}; + + # ACME challenge accessible avant l'émission du certificat + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + +{% endif %} + root {{ frontend_dir }}/dist; + index index.html; + client_max_body_size {{ nginx_max_body_size }}; + + location /api/ { + limit_except GET POST PUT PATCH DELETE OPTIONS { deny all; } + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' "$http_origin" always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Request-ID' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Length' 0; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + return 204; + } + + proxy_pass https://backend; + proxy_http_version 1.1; + + proxy_ssl_server_name on; + proxy_ssl_name uber-stup.club; + + proxy_set_header Host uber-stup.club; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $req_id; + proxy_set_header Connection ""; + + proxy_connect_timeout {{ nginx_proxy_timeout }}s; + proxy_send_timeout {{ nginx_proxy_timeout }}s; + proxy_read_timeout {{ nginx_proxy_timeout }}s; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + location /uploads/ { + alias {{ uploads_dir }}/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location ^~ /uploads/images/ { + alias {{ uploads_dir }}/images/; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + + expires 30d; + add_header Cache-Control "public, immutable" always; + + types { + image/jpeg jpg jpeg; + image/png png; + image/gif gif; + image/webp webp; + image/svg+xml svg; + } + default_type image/jpeg; + } + + location ^~ /uploads/videos/ { + alias {{ uploads_dir }}/videos/; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + + expires 30d; + add_header Cache-Control "public, immutable" always; + + types { + video/mp4 mp4; + video/webm webm; + video/ogg ogv; + } + default_type video/mp4; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +{% if ssl_enabled %} + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; +{% endif %} + add_header Content-Security-Policy " + default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: https:; + font-src 'self' data:; + connect-src 'self' https://api.tomtom.com https://uber-stup.club; + " always; + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~* (\.env|\.git|package\.json|package-lock\.json|yarn\.lock|Dockerfile|docker-compose\.yml)$ { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 258df550..3021748c 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -432,8 +432,8 @@ func (d *Database) GetBasketItems(username string) ([]map[string]interface{}, er var items []map[string]interface{} for rows.Next() { - var productID, quantity int - var price float64 + var productID int + var quantity, price float64 if err := rows.Scan(&productID, &quantity, &price); err != nil { return nil, err } diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index 032dcefe..88945ed0 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -876,7 +876,7 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, type ItemPoints struct { Category string - Quantite int + Quantite float64 Prix float64 } @@ -894,8 +894,11 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, items = append(items, item) // Cumuler par catégorie - if item.Category == "zipette" { + categoryLower := strings.ToLower(item.Category) + if categoryLower == "zipette&co" || categoryLower == "zipette_co" { totalPrixZipette += item.Prix + } else if categoryLower == "gros&semi" || categoryLower == "gros_semi" { + // gros&semi → 0 points, on ne cumule pas } else { // weed_hash ou autres catégories totalPrixWeedHash += item.Prix @@ -915,10 +918,31 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, log.Printf("📊 [CalcPointsTx] %d items - weed_hash: %.2f€, zipette: %.2f€", len(items), totalPrixWeedHash, totalPrixZipette) - // ✅ ÉTAPE 2: Calculer les points par catégorie - // Règle: 1 point par tranche de 10€ - pointsWeedHash := int(totalPrixWeedHash / 10.0) - pointsZipette := int(totalPrixZipette / 10.0) + // ✅ ÉTAPE 2: Calculer les points par catégorie avec le bon barème + pointsWeedHash := 0 + switch { + case totalPrixWeedHash >= 30 && totalPrixWeedHash <= 50: + pointsWeedHash = 1 + case totalPrixWeedHash >= 60 && totalPrixWeedHash <= 150: + pointsWeedHash = 2 + case totalPrixWeedHash >= 160 && totalPrixWeedHash <= 300: + pointsWeedHash = 3 + case totalPrixWeedHash >= 310 && totalPrixWeedHash <= 400: + pointsWeedHash = 5 + case totalPrixWeedHash >= 400: + pointsWeedHash = 10 + } + + pointsZipette := 0 + switch { + case totalPrixZipette >= 30 && totalPrixZipette <= 100: + pointsZipette = 1 + case totalPrixZipette >= 110 && totalPrixZipette <= 200: + pointsZipette = 2 + case totalPrixZipette >= 210: + pointsZipette = 3 + } + totalPoints := pointsWeedHash + pointsZipette log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d", diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go index fb23becb..dadf876d 100644 --- a/backend/gestion/db/db_command_items.go +++ b/backend/gestion/db/db_command_items.go @@ -31,7 +31,7 @@ func validateItemID(itemID int) error { } return nil } -func validateQuantite(quantite int) error { +func validateQuantite(quantite float64) error { if quantite <= 0 { return fmt.Errorf("quantité doit être > 0") } @@ -96,7 +96,8 @@ func validateItemStatus(status string) error { func (d *Database) InsertCommandItemWithClientInfo( commandID int, produit string, - productID, quantite int, + productID int, + quantite float64, prix float64, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string, ) error { @@ -231,7 +232,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err var items []map[string]interface{} for rows.Next() { - var id, commandID, quantite int + var id, commandID int + var quantite float64 var productID sql.NullInt64 // ✅ FIX: Utiliser NullInt64 pour gérer NULL var produit, clientUsername, clientNom, clientPrenom, clientTelephone string var deliveryAddress, status sql.NullString @@ -345,7 +347,8 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte var items []map[string]interface{} for rows.Next() { - var id, commandID, quantite int + var id, commandID int + var quantite float64 var productID sql.NullInt64 // ✅ FIX: NullInt64 var produit, clientUsername, clientNom, clientPrenom, clientTelephone string var deliveryAddress, status sql.NullString diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 581d3de6..9c4572e1 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -82,7 +82,7 @@ func (d *Database) CreateCommand(username string) (*models.Command, error) { type BasketItem struct { ProductID int - Quantity int + Quantity float64 Price float64 } @@ -190,7 +190,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* type BasketItem struct { ProductID int - Quantity int + Quantity float64 Price float64 } diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 77c7a80d..a5b95829 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -76,11 +76,55 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration must_change_password: %v", err) } - // Migration: ajouter colonne push_token pour les notifications push + // Migration: ajouter colonne push_token pour les notifications push (clients) if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS push_token TEXT`); err != nil { log.Fatalf("❌ Erreur migration push_token: %v", err) } + // Migration: ajouter colonne push_token pour les notifications push (livreurs/users) + if _, err = database.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS push_token TEXT`); err != nil { + log.Fatalf("❌ Erreur migration push_token users: %v", err) + } + + // Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u) + if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil { + log.Fatalf("❌ Erreur migration unit products: %v", err) + } + + // Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g) + if _, err = database.Exec(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'baskets' AND column_name = 'quantity' + AND data_type = 'integer' + ) THEN + ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3); + END IF; + END + $$; + `); err != nil { + log.Fatalf("❌ Erreur migration baskets.quantity: %v", err) + } + + // Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires + if _, err = database.Exec(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'command_items' AND column_name = 'quantite' + AND data_type = 'integer' + ) THEN + ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3); + END IF; + END + $$; + `); err != nil { + log.Fatalf("❌ Erreur migration command_items.quantite: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() @@ -151,6 +195,7 @@ func (db *Database) createTables() error { name VARCHAR(255) NOT NULL, category VARCHAR(100) NOT NULL, stock DECIMAL(10,2) NOT NULL DEFAULT 0, + unit VARCHAR(10) NOT NULL DEFAULT 'u', description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go index 20be2e47..f623ad95 100644 --- a/backend/gestion/db/db_notifications.go +++ b/backend/gestion/db/db_notifications.go @@ -2,6 +2,7 @@ package db import ( "bytes" + "database/sql" "encoding/json" "fmt" "log" @@ -36,11 +37,15 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa } func sendExpoPush(token, title, body string, commandID int, notifType string) { + sendExpoPushWithChannel(token, title, body, commandID, notifType, "orders") +} + +func sendExpoPushWithChannel(token, title, body string, commandID int, notifType, channelID string) { payload := map[string]interface{}{ "to": token, "title": title, "body": body, - "channelId": "orders", + "channelId": channelID, "data": map[string]interface{}{ "command_id": commandID, "type": notifType, @@ -71,7 +76,59 @@ func sendExpoPush(token, title, body string, commandID int, notifType string) { } defer resp.Body.Close() - log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (status: %d)", token, resp.StatusCode) + log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (channel: %s, status: %d)", token, channelID, resp.StatusCode) +} + +// ============================================ +// PUSH TOKEN - LIVREURS (table users) +// ============================================ + +// SaveUserPushToken sauvegarde le token push d'un livreur dans la table users +func (d *Database) SaveUserPushToken(username, token string) error { + _, err := d.Exec(`UPDATE users SET push_token = $1 WHERE username = $2`, token, username) + return err +} + +// GetUserPushToken récupère le token push d'un livreur depuis la table users +func (d *Database) GetUserPushToken(username string) (string, error) { + var token sql.NullString + err := d.QueryRow(`SELECT push_token FROM users WHERE username = $1`, username).Scan(&token) + if err != nil || !token.Valid { + return "", err + } + return token.String, nil +} + +// DeleteUserPushToken supprime le token push d'un livreur +func (d *Database) DeleteUserPushToken(username string) error { + _, err := d.Exec(`UPDATE users SET push_token = NULL WHERE username = $1`, username) + return err +} + +// NotifyLivreur envoie une notification in-app (Redis) + push Expo à un livreur +func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error { + notifKey := fmt.Sprintf("notifications:%s", username) + + notification := map[string]interface{}{ + "command_id": commandID, + "type": notifType, + "message": message, + "created_at": time.Now().Format(time.RFC3339), + "read": false, + } + + notifJSON, _ := json.Marshal(notification) + Redis.LPush(RedisCtx, notifKey, notifJSON) + Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) + + // Push notification si le livreur a un token enregistré + pushToken, err := d.GetUserPushToken(username) + if err == nil && pushToken != "" { + go sendExpoPushWithChannel(pushToken, "Nouvelle commande assignée", message, commandID, notifType, "deliveries") + } + + log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message) + return nil } // AddDeliveryRating ajoute une note pour un livreur diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go index f6512e32..a3252bfa 100644 --- a/backend/gestion/db/db_product.go +++ b/backend/gestion/db/db_product.go @@ -17,7 +17,8 @@ func (db *Database) CreateProduct(product interface{}) error { GetName() string GetCategory() string GetDescription() string - GetStock() float64 // ← ajouter méthode pour le stock + GetStock() float64 + GetUnit() string GetPrices() []models.ProductPrice SetID(int) SetCreatedAt(time.Time) @@ -45,15 +46,15 @@ func (db *Database) CreateProduct(product interface{}) error { log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices())) } - // Insérer le produit avec le stock - query := `INSERT INTO products (name, category, description, stock, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at` + // Insérer le produit avec le stock et l'unité + query := `INSERT INTO products (name, category, description, stock, unit, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at` now := time.Now() var productID int var createdAt, updatedAt time.Time - err := db.QueryRow(query, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), now, now). + err := db.QueryRow(query, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now). Scan(&productID, &createdAt, &updatedAt) if err != nil { log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err) @@ -93,10 +94,10 @@ func (d *Database) GetProductByID(id int) (models.Product, error) { // ✅ AJOUTER stock dans le SELECT err := d.QueryRow(` - SELECT id, name, category, description, stock, created_at, updated_at + SELECT id, name, category, description, stock, unit, created_at, updated_at FROM products WHERE id=$1 - `, id).Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt) + `, id).Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt) if err != nil { log.Printf("❌ [GetProductByID] Erreur query: %v", err) @@ -122,7 +123,7 @@ func (d *Database) GetAllProducts() ([]models.Product, error) { log.Println("📦 [GetAllProducts] START") rows, err := d.Query(` - SELECT id, name, category, description, stock, created_at, updated_at + SELECT id, name, category, description, stock, unit, created_at, updated_at FROM products ORDER BY id ASC `) @@ -135,7 +136,7 @@ func (d *Database) GetAllProducts() ([]models.Product, error) { var products []models.Product for rows.Next() { var p models.Product - if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt); err != nil { log.Printf("❌ [GetAllProducts] Erreur scan: %v", err) return nil, err } @@ -173,9 +174,8 @@ func (d *Database) GetAllProducts() ([]models.Product, error) { func (db *Database) GetProductsByCategory(category string) ([]models.Product, error) { log.Printf("📦 [GetProductsByCategory] START - Category=%s", category) - // ✅ AJOUTER stock dans le SELECT rows, err := db.Query(` - SELECT id, name, category, description, stock, created_at, updated_at + SELECT id, name, category, description, stock, unit, created_at, updated_at FROM products WHERE category = $1 ORDER BY created_at DESC @@ -190,8 +190,7 @@ func (db *Database) GetProductsByCategory(category string) ([]models.Product, er var products []models.Product for rows.Next() { var p models.Product - // ✅ AJOUTER &p.Stock dans le Scan - if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt); err != nil { log.Printf("❌ [GetProductsByCategory] Erreur scan: %v", err) return nil, fmt.Errorf("erreur lors du scan d'un produit: %w", err) } diff --git a/backend/gestion/handlers/cabine.go b/backend/gestion/handlers/cabine.go index cfd3c414..a4e51fb4 100644 --- a/backend/gestion/handlers/cabine.go +++ b/backend/gestion/handlers/cabine.go @@ -668,6 +668,12 @@ func ForceValidateDelivery(c *gin.Context) { clientUsername, _ := command["username"].(string) livreurAssign, _ := command["livreur_assign"].(string) + // Notifier le client + if clientUsername != "" { + clientMsg := fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID) + database.NotifyClient(clientUsername, commandID, "livre", clientMsg) + } + if err := database.IncrementClientCommandCount(clientUsername); err != nil { log.Printf("⚠️ Erreur compteur commandes: %v", err) } diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index 565176d5..a08af832 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -359,6 +359,31 @@ func UpdateDeliveryStatus(c *gin.Context) { } database.AddCommandLog(commandID, req.Status, message, usernameStr) + // ✅ NOTIFICATION CLIENT + clientUsername, _ := command["username"].(string) + if clientUsername != "" { + var clientMsg string + switch req.Status { + case "support": + clientMsg = fmt.Sprintf("Votre commande #%d est prise en charge", commandID) + case "en_route": + if etaMinutes > 0 { + clientMsg = fmt.Sprintf("Votre commande #%d est en route ! Arrivée dans ~%d min", commandID, etaMinutes) + } else { + clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID) + } + case "arrived": + clientMsg = fmt.Sprintf("Votre livreur est arrivé pour la commande #%d", commandID) + case "livre": + clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID) + case "failed": + clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID) + } + if clientMsg != "" { + database.NotifyClient(clientUsername, commandID, req.Status, clientMsg) + } + } + // ✅ GESTION SPÉCIALE SELON LE STATUT switch req.Status { case "livre": diff --git a/backend/gestion/handlers/notifications.go b/backend/gestion/handlers/notifications.go index 003034d5..734aebc3 100644 --- a/backend/gestion/handlers/notifications.go +++ b/backend/gestion/handlers/notifications.go @@ -107,6 +107,144 @@ func GetClientNotifications(c *gin.Context) { }) } +// RegisterLivreurPushToken enregistre le push token Expo d'un livreur +// POST /api/v1/livreur/push-token +func RegisterLivreurPushToken(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + var req struct { + PushToken string `json:"push_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"}) + return + } + + database := c.MustGet("database").(*db.Database) + if err := database.SaveUserPushToken(username, req.PushToken); err != nil { + log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur sauvegarde token pour %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"}) + return + } + + log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token enregistré pour livreur %s", username) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// UnregisterLivreurPushToken supprime le push token d'un livreur (au logout) +// DELETE /api/v1/livreur/push-token +func UnregisterLivreurPushToken(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + database := c.MustGet("database").(*db.Database) + if err := database.DeleteUserPushToken(username); err != nil { + log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur suppression token pour %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"}) + return + } + + log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token supprimé pour livreur %s", username) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// GetLivreurNotifications retourne les notifications du livreur connecté +// GET /api/v1/livreur/notifications +func GetLivreurNotifications(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + notifKey := "notifications:" + username + + results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() + if err != nil { + log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"}) + return + } + + type Notification struct { + CommandID int `json:"command_id"` + Type string `json:"type"` + Message string `json:"message"` + CreatedAt string `json:"created_at"` + Read bool `json:"read"` + } + + notifications := make([]Notification, 0, len(results)) + unreadCount := 0 + + for _, raw := range results { + var n Notification + if err := json.Unmarshal([]byte(raw), &n); err != nil { + continue + } + notifications = append(notifications, n) + if !n.Read { + unreadCount++ + } + } + + log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount) + + c.JSON(http.StatusOK, gin.H{ + "notifications": notifications, + "unread_count": unreadCount, + "total": len(notifications), + }) +} + +// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues +// POST /api/v1/livreur/notifications/read +func MarkLivreurNotificationsRead(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + notifKey := "notifications:" + username + + results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result() + if err != nil { + log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"}) + return + } + + markedCount := 0 + for i, raw := range results { + var n map[string]interface{} + if err := json.Unmarshal([]byte(raw), &n); err != nil { + continue + } + if read, ok := n["read"].(bool); ok && read { + continue + } + n["read"] = true + updated, _ := json.Marshal(n) + db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated)) + markedCount++ + } + + log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "marked_count": markedCount, + }) +} + // MarkNotificationsRead marque toutes les notifications comme lues // POST /api/v1/notifications/read func MarkNotificationsRead(c *gin.Context) { diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index 2a93f01a..c8c2180a 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -5,6 +5,7 @@ package handlers import ( + "fmt" "gestion/db" "gestion/models" "gestion/services" @@ -352,6 +353,45 @@ func ValidateBasket(c *gin.Context) { log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) + // ============================================ + // 1️⃣b Vérifier le minimum de commande selon la zone + // ============================================ + var cartTotal float64 + for _, item := range items { + if price, ok := item["price"].(float64); ok { + cartTotal += price + } + } + + zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal) + if !zoneResult.OK { + if zoneResult.ZoneName == "inconnue" { + log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Adresse invalide : aucun code postal détecté", + }) + } else if zoneResult.ZoneName == "hors zone" { + log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Livraison non disponible pour ce code postal", + "postal_code": zoneResult.PostalCode, + }) + } else { + log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount) + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount), + "zone": zoneResult.ZoneName, + "minimum": zoneResult.MinAmount, + "cart_total": cartTotal, + "missing": zoneResult.MinAmount - cartTotal, + "postal_code": zoneResult.PostalCode, + }) + } + return + } + + log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount) + // ============================================ // 2️⃣ Créer la commande (qui décrémente automatiquement le stock) // ============================================ @@ -439,6 +479,16 @@ func ValidateBasket(c *gin.Context) { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) } + // Notifier le livreur de la nouvelle commande + notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) + if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { + log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) + } + + // Notifier le client + clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime) + database.NotifyClient(usernameStr, commandID, "assigned", clientMsg) + assigned = true assignInfo = gin.H{ "username": nearest.Username, diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 8a046a7b..070a5681 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -114,6 +114,22 @@ func validatePrice(quantity float64, price float64) error { return nil } +func validateUnit(unit string) error { + validUnits := map[string]bool{ + "u": true, // unité + "kg": true, // kilogramme + "g": true, // gramme + "bag": true, // sac + "l": true, // litre + "cl": true, // centilitre + "pcs": true, // pièces + } + if !validUnits[unit] { + return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs") + } + return nil +} + func validateCategory(category string) error { // Nettoyage category = strings.ToLower(strings.TrimSpace(category)) @@ -205,6 +221,10 @@ func CreateProduct(c *gin.Context) { category := strings.TrimSpace(c.PostForm("category")) description := strings.TrimSpace(c.PostForm("description")) stockStr := c.PostForm("stock") + unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit"))) + if unit == "" { + unit = "u" + } // ✅ VALIDATION STRICTE if err := validateProductName(name); err != nil { @@ -231,6 +251,11 @@ func CreateProduct(c *gin.Context) { return } + if err := validateUnit(unit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // ✅ VALIDER LE STOCK stock, err := strconv.ParseFloat(stockStr, 64) if err != nil { @@ -296,6 +321,7 @@ func CreateProduct(c *gin.Context) { Category: category, Description: description, Stock: stock, + Unit: unit, Prices: prices, } @@ -581,6 +607,7 @@ func UpdateProduct(c *gin.Context) { Category string `json:"category"` Description string `json:"description"` Stock float64 `json:"stock"` + Unit string `json:"unit"` Prices []models.ProductPrice `json:"prices"` } @@ -605,6 +632,14 @@ func UpdateProduct(c *gin.Context) { return } + if updateData.Unit == "" { + updateData.Unit = "u" + } + if err := validateUnit(updateData.Unit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := validateStock(updateData.Stock); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -627,8 +662,8 @@ func UpdateProduct(c *gin.Context) { // ✅ UPDATE PRODUIT updateQuery := ` UPDATE products - SET name = $1, category = $2, description = $3, stock = $4, updated_at = $5 - WHERE id = $6 + SET name = $1, category = $2, description = $3, stock = $4, unit = $5, updated_at = $6 + WHERE id = $7 ` _, err = database.Exec(updateQuery, @@ -636,6 +671,7 @@ func UpdateProduct(c *gin.Context) { updateData.Category, updateData.Description, updateData.Stock, + updateData.Unit, time.Now(), id, ) diff --git a/backend/gestion/handlers/zones.go b/backend/gestion/handlers/zones.go new file mode 100644 index 00000000..d67e626e --- /dev/null +++ b/backend/gestion/handlers/zones.go @@ -0,0 +1,124 @@ +package handlers + +import "regexp" + +// ============================================================ +// Zones de livraison — minimum de commande par code postal +// ============================================================ +// Remplis les listes de codes postaux quand tu les as. +// Un code postal absent de toutes les zones → commande refusée. +// ============================================================ + +type deliveryZone struct { + Name string + MinAmount float64 + codes map[string]struct{} +} + +var deliveryZones = []deliveryZone{ + { + Name: "Zone 30€", + MinAmount: 30.0, + codes: postalSet([]string{ + "44000", + "44100", + "44200", + "44300", + }), + }, + { + Name: "Zone 50€", + MinAmount: 50.0, + codes: postalSet([]string{ + "44400", // Rezé + "44880", // Les Sorinières / Sautron + "44120", // Vertou + "44230", // Saint-Sébastien-sur-Loire + "44115", // Basse-Goulaine / Haute-Goulaine + "44980", // Sainte-Luce-sur-Loire + "44470", // Carquefou + "44240", // La Chapelle-sur-Erdre + "44700", // Orvault + "44800", // Saint-Herblain + "44340", // Bouguenais + "44620", // La Montagne + "44830", // Bouaye + }), + }, + { + Name: "Zone 100€", + MinAmount: 100.0, + codes: postalSet([]string{ + "44860", // Pont-Saint-Martin / Saint-Aignan-Grandlieu + "44220", // Couëron + "44118", // La Chevrolière + "44830", // Brains + "44710", // Saint-Léger-les-Vignes + "44690", // La Haie-Fouassière + "44470", // Mauves-sur-Loire + "44240", // Sucé-sur-Erdre + "44119", // Grandchamp-des-Fontaines + }), + }, +} + +var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`) + +// postalSet convertit une slice de codes en set pour lookup O(1). +func postalSet(codes []string) map[string]struct{} { + m := make(map[string]struct{}, len(codes)) + for _, c := range codes { + m[c] = struct{}{} + } + return m +} + +// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse. +func extractPostalCode(address string) string { + m := postalCodeRe.FindStringSubmatch(address) + if len(m) < 2 { + return "" + } + return m[1] +} + +// zoneCheckResult est le résultat de la vérification de zone. +type zoneCheckResult struct { + PostalCode string + ZoneName string + MinAmount float64 + OK bool +} + +// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse. +// Code postal introuvable → OK = false (refus). +// Code postal hors de toutes les zones → OK = false (refus). +func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult { + code := extractPostalCode(deliveryAddress) + if code == "" { + return zoneCheckResult{ + PostalCode: "", + ZoneName: "inconnue", + MinAmount: 0, + OK: false, + } + } + + for _, zone := range deliveryZones { + if _, found := zone.codes[code]; found { + return zoneCheckResult{ + PostalCode: code, + ZoneName: zone.Name, + MinAmount: zone.MinAmount, + OK: total >= zone.MinAmount, + } + } + } + + return zoneCheckResult{ + PostalCode: code, + ZoneName: "hors zone", + MinAmount: 0, + OK: false, + } +} diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 68a77c30..d27e4600 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -96,9 +96,9 @@ func main() { // Configuration CORS r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"https://uber-stup.club", "http://localhost:5173"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) diff --git a/backend/gestion/middleware/clock_middleware.go b/backend/gestion/middleware/clock_middleware.go index 24b99d5d..a5ba69df 100644 --- a/backend/gestion/middleware/clock_middleware.go +++ b/backend/gestion/middleware/clock_middleware.go @@ -9,7 +9,11 @@ import ( ) func OrderHoursMiddleware(c *gin.Context) { - now := time.Now() + loc, err := time.LoadLocation("Europe/Paris") + if err != nil { + loc = time.UTC + } + now := time.Now().In(loc) hour := now.Hour() min := now.Minute() diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index 95e2f477..defc823a 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -8,6 +8,7 @@ type Product struct { Category string `json:"category" binding:"required"` Description string `json:"description"` Stock float64 `json:"stock"` // ← ajouter le stock ici + Unit string `json:"unit"` // kg | g | bag | l | cl | pcs | u Prices []ProductPrice `json:"prices"` Media []Media `json:"media,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -38,7 +39,8 @@ type StockInfo struct { func (p *Product) GetName() string { return p.Name } func (p *Product) GetCategory() string { return p.Category } func (p *Product) GetDescription() string { return p.Description } -func (p *Product) GetStock() float64 { return p.Stock } // ← méthode pour le stock +func (p *Product) GetStock() float64 { return p.Stock } +func (p *Product) GetUnit() string { return p.Unit } func (p *Product) GetPrices() []ProductPrice { return p.Prices } func (p *Product) SetID(id int) { p.ID = id } func (p *Product) SetCreatedAt(t time.Time) { p.CreatedAt = t } diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index d63e0abc..30e14722 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -157,7 +157,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.POST("/products", handlers.CreateProduct) adminGroupV2.GET("/products", handlers.GetAllProducts) adminGroupV2.GET("/products/:id", handlers.GetProductByID) - adminGroupV2.PUT("/products/update/:id", handlers.UpdateProduct) + adminGroupV2.PUT("/products/:id", handlers.UpdateProduct) adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct) adminGroupV2.POST("/products/:id/media", handlers.UploadMedia) adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia) @@ -292,6 +292,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services livreurGroupV1.DELETE("/alert/:id", handlers.EndAlert) // Mettre fin à une alerte livreurGroupV1.GET("/alerts", handlers.GetMyAlerts) // Voir mes alertes livreurGroupV1.GET("/alert/:id", handlers.GetAlert) // Détail d'une alerte + + // ============================================ + // NOTIFICATIONS LIVREUR + // ============================================ + livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications) + livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead) + livreurGroupV1.POST("/push-token", handlers.RegisterLivreurPushToken) + livreurGroupV1.DELETE("/push-token", handlers.UnregisterLivreurPushToken) } } diff --git a/backend/gestion/workers/cron_auto_assign.go b/backend/gestion/workers/cron_auto_assign.go index ecf61891..56e5702b 100644 --- a/backend/gestion/workers/cron_auto_assign.go +++ b/backend/gestion/workers/cron_auto_assign.go @@ -182,6 +182,20 @@ func tryAssignCommandWithPriority( database.AddCommandLog(commandID, "assigned", logMessage, "system-cron") + // 8. Notifier le livreur de la nouvelle commande + notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) + if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { + log.Printf("⚠️ [CRON] Erreur notification livreur %s: %v", nearest.Username, notifErr) + } + + // 9. Notifier le client + if cmd, err := database.GetCommandByID(commandID); err == nil { + if clientUsername, ok := cmd["username"].(string); ok && clientUsername != "" { + clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime) + database.NotifyClient(clientUsername, commandID, "assigned", clientMsg) + } + } + log.Printf("✅ [CRON] Cmd %d → %s (%.2f km) | Priorité #%d | Attente: %d min", commandID, nearest.Username, distance, priority, waitingMinutes) diff --git a/ansible/templates/custom-rules.conf.j2 b/docker/frontend/custom-rules.conf similarity index 100% rename from ansible/templates/custom-rules.conf.j2 rename to docker/frontend/custom-rules.conf diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 34a3532f..eee96a2e 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -7,20 +7,16 @@ map $http_x_request_id $req_id { } # ========================================================= -# Upstream backend +# Backend distant # ========================================================= upstream backend { - server backend:8080 max_fails=3 fail_timeout=30s; + server uber-stup.club:443; keepalive 32; - keepalive_requests 100; - keepalive_timeout 60s; } # ========================================================= # HTTP Server (MAIN) # ========================================================= -# Supprime le premier server block et garde seulement : - server { listen 80; listen [::]:80; @@ -46,13 +42,16 @@ server { return 204; } - proxy_pass http://backend; + proxy_pass https://backend; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_ssl_server_name on; + proxy_ssl_name uber-stup.club; + + proxy_set_header Host uber-stup.club; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; # ✅ Changé + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $req_id; proxy_set_header Connection ""; @@ -140,7 +139,7 @@ server { style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; - connect-src 'self' https://api.tomtom.com; + connect-src 'self' https://api.tomtom.com https://uber-stup.club; " always; location ~ /\. { diff --git a/frontend-prep/.env b/frontend-prep/.env index eb9dfb30..c80a63ee 100644 --- a/frontend-prep/.env +++ b/frontend-prep/.env @@ -1,3 +1 @@ -# TomTom API Key -# Obtenez votre cle API gratuite sur https://developer.tomtom.com/ VITE_TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB diff --git a/frontend-prep/package-lock.json b/frontend-prep/package-lock.json index edced25d..62db288a 100644 --- a/frontend-prep/package-lock.json +++ b/frontend-prep/package-lock.json @@ -13,6 +13,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", "@tomtom-international/web-sdk-maps": "^6.25.0", + "baseline-browser-mapping": "^2.10.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1823,12 +1824,14 @@ "dev": true }, "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", - "dev": true, + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { diff --git a/frontend-prep/package.json b/frontend-prep/package.json index c9ca6e74..b6a28ba4 100644 --- a/frontend-prep/package.json +++ b/frontend-prep/package.json @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", "@tomtom-international/web-sdk-maps": "^6.25.0", + "baseline-browser-mapping": "^2.10.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend-prep/src/App.tsx b/frontend-prep/src/App.tsx index cf57f814..1d672d6f 100644 --- a/frontend-prep/src/App.tsx +++ b/frontend-prep/src/App.tsx @@ -11,46 +11,19 @@ import ConsultationHistorique from "./pages/User/ConsultationHistorique"; import ProductDetail from "./pages/User/ProductDetail"; import Cart from "./pages/User/Cart"; import Checkout from "./pages/User/Checkout"; +import OrderDetails from "./pages/User/OrderDetails"; -import DashboardAdmin from "./pages/Admin/Dashboard"; // Pages Login import LoginClient from "./pages/LoginClient/Login"; -import RegisterClient from "./pages/RegisterClient/Register"; -import LoginAdmin from "./pages/LoginAdmin/Login"; -import AdminOrders from "./pages/AdminOrders/AdminOrders"; -import AdminUsers from "./pages/AdminUsers/AdminUsers"; -import AdminProduct from "./pages/AdminProduct/AdminProduct"; -import AdminDeliverymen from "./pages/AdminManageDeliveryMan/AdminDeliveryMen"; -import LoginPageCabinne from "./pages/LoginPageCabinne/LoginPage"; -import CabineDashboard from "./pages/Cabine/CabineDashboard"; -import CabineOrders from "./pages/CabineOrders/CabineOrders"; -import CabineDeliverymen from "./pages/CabineDeliveryMen/CabineDeliveryMen"; -import CabineUserManage from "./pages/CabineUserManager/UserManagement"; -import DeliveryLogin from "./pages/LoginLivreur/LoginLivreur"; -import DeliveryDashboard from "./pages/Livreur/DeliveryDashboard"; -import StatsPage from "./pages/Livreur/StatsPage"; -import OrderDetails from "./pages/User/OrderDetails"; -import AlertHistory from "./pages/Livreur/AlertHistory"; -import CabineAlerts from "./pages/CabineAlert/CabineAlerts"; -import AdminAlerts from "./pages/AdminAlerts/AdminAlerts"; +import ChangePasswordPage from "./pages/ChangePassword/ChangePassword"; + function App() { return ( {/* Routes Login - SANS CartProvider */} } /> - } /> - - {/* Routes login - Admin */} - } /> - } - /> - } - /> + } /> {/* Page d'accueil */} } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> + {/* Routes User - Panier */} } /> } /> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> } diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 56fba264..194c3425 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -5,7 +5,7 @@ // ✅ loginUser et registerUser retournent AuthResponse // ✅ sessionStorage (pas localStorage) -const API_URL = "/api/v1"; +const API_URL = "https://uber-stup.club/api/v1"; import type { ConfirmReceptionResponse, CheckoutCartResponse, @@ -36,6 +36,7 @@ export interface AuthResponse { telephone?: string; role?: string; session_id?: string; + must_change_password?: boolean; }; } @@ -177,11 +178,16 @@ export const loginUser = async ( let errorMessage = "Erreur de connexion"; try { const errorData = await response.json(); - errorMessage = errorData.error || errorData.message || errorMessage; + errorMessage = + errorData.error || errorData.message || errorMessage; } catch { // body vide ou non-JSON } - console.error("❌ [LOGIN] Erreur API:", response.status, errorMessage); + console.error( + "❌ [LOGIN] Erreur API:", + response.status, + errorMessage, + ); return { success: false, message: errorMessage, @@ -235,97 +241,6 @@ export const loginUser = async ( } }; -/** - * ✅ REGISTER - Retourne AuthResponse avec access_token - * POST /api/v1/auth/register - */ -export const registerUser = async ( - username: string, - password: string, - nom: string, - prenom: string, - telephone: string, -): Promise => { - // ✅ Type de retour CORRECT - try { - console.log("📝 [REGISTER] Appel API..."); - - const response = await fetch(`${API_URL}/auth/register`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - username, - password, - nom, - prenom, - telephone, - }), - }); - - if (!response.ok) { - let errorMessage = "Erreur d'inscription"; - try { - const errorData = await response.json(); - errorMessage = errorData.error || errorData.message || errorMessage; - } catch { - // body vide ou non-JSON - } - console.error("❌ [REGISTER] Erreur API:", response.status, errorMessage); - return { - success: false, - message: errorMessage, - }; - } - - const data = await response.json(); - console.log("📋 [REGISTER] Réponse:", data); - - // ✅ Vérifier access_token - if (!data.access_token) { - console.error("❌ [REGISTER] Pas de access_token"); - return { - success: false, - message: "Token non reçu du serveur", - }; - } - - // ✅ Stocker en sessionStorage - sessionStorage.setItem("token", data.access_token); - console.log("✅ [REGISTER] Token stocké"); - - // ✅ Synchroniser username - const jwtUsername = syncUsernameFromJWT(); - if (!jwtUsername) { - console.warn("⚠️ [REGISTER] Impossible de synchroniser username"); - return { - success: false, - message: "Erreur synchronisation JWT", - }; - } - - console.log(`✅ [REGISTER] Créé et connecté: ${jwtUsername}`); - - // ✅ Retourner avec access_token - return { - success: true, - message: "Inscription réussie", - access_token: data.access_token, // ✅ IMPORTANT! - token_type: data.token_type, - expires_in: data.expires_in, - user: data.user, - }; - } catch (error) { - console.error("❌ [REGISTER] Erreur:", error); - return { - success: false, - message: - error instanceof Error ? error.message : "Erreur d'inscription", - }; - } -}; - /** * ✅ LOGOUT */ @@ -351,6 +266,43 @@ export const logoutUser = async (): Promise => { console.log("✅ [LOGOUT] sessionStorage nettoyé"); }; +/** + * ✅ CHANGE PASSWORD + * PUT /api/v1/auth/change-password + */ +export const changePassword = async ( + currentPassword: string, + newPassword: string, +): Promise<{ success: boolean; message: string }> => { + const token = sessionStorage.getItem("token"); + try { + const response = await fetch(`${API_URL}/auth/change-password`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + const data = await response.json(); + if (!response.ok) { + return { + success: false, + message: data.error || data.message || "Erreur lors du changement de mot de passe", + }; + } + return { success: true, message: data.message || "Mot de passe mis à jour" }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Erreur de connexion", + }; + } +}; + // ============================================ // 🛒 PANIER // ============================================ @@ -1765,3 +1717,76 @@ export const checkoutCart = async ( }; } }; + +// ============================================ +// 🔔 NOTIFICATIONS CLIENT +// ============================================ + +export interface ClientNotification { + command_id: number; + type: string; + message: string; + created_at: string; + read: boolean; +} + +export interface NotificationsResponse { + success: boolean; + notifications: ClientNotification[]; + unread_count: number; + total: number; +} + +export const getClientNotifications = + async (): Promise => { + const token = getAuthToken(); + if (!token) + return { + success: false, + notifications: [], + unread_count: 0, + total: 0, + }; + try { + const response = await fetch(`${API_URL}/notifications`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) + return { + success: false, + notifications: [], + unread_count: 0, + total: 0, + }; + const data = await response.json(); + return { + success: true, + notifications: data.notifications || [], + unread_count: data.unread_count || 0, + total: data.total || 0, + }; + } catch { + return { + success: false, + notifications: [], + unread_count: 0, + total: 0, + }; + } + }; + +export const markNotificationsRead = async (): Promise<{ + success: boolean; +}> => { + const token = getAuthToken(); + if (!token) return { success: false }; + try { + const response = await fetch(`${API_URL}/notifications/read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + return { success: response.ok }; + } catch { + return { success: false }; + } +}; diff --git a/frontend-prep/src/api/api_admin.ts b/frontend-prep/src/api/api_admin.ts deleted file mode 100644 index a30bd2f2..00000000 --- a/frontend-prep/src/api/api_admin.ts +++ /dev/null @@ -1,2652 +0,0 @@ -// ============================================ -// api/api_admin.ts - ADMIN API -// ============================================ -// ✅ Gestion complète de l'authentification admin -// ✅ Utilise /api/v2/admin/* endpoints -// ✅ sessionStorage pour la persistance - -import type { AdminResponse } from "./api_admin_types"; -import type { - ProductListResponse, - ProductResponse, - Product, - DeleteResponse, - CreateProductData, - DeliveryPersonDetails, - DeliveryPersonStats, -} from "./api_admin_types"; -const API_URL = "/api/v2"; - -// ============================================ -// 🔐 TYPES - ADMIN -// ============================================ -/** - * ✅ Admin User Interface - */ -export interface AdminUser { - id: number; - username: string; - role: string; -} - -/** - * ✅ Admin Auth Response - */ -export interface AdminAuthResponse { - success: boolean; - message?: string; - access_token?: string; - token_type?: string; - expires_in?: number; - user?: AdminUser; -} - -/** - * ✅ Client Response - Mise à jour avec champs optionnels - */ -export interface ClientResponse { - id: number; - username: string; - nom: string; - prenom: string; - telephone: string; - adresse?: string; - role?: string; - created_at?: string; - updated_at?: string; - is_active?: boolean; - command: number; - point: number; // Points weed/hash - points_zipette: number; // ✅ NOUVEAU: Points zipette - amende: number; - cancellations_count: number; - last_penalty_reason?: string; - total_orders?: number; - completed_deliveries?: number; - total_spent?: number; -} - -export interface CommandResponse { - id: number; - username: string; - status: string; - adresse: string; - total_prix: number; - livreur_assign?: string | null; // peut être null - created_at: string; // ISO date string - updated_at: string; // ISO date string -} - -export interface AllCommandResponse { - success: boolean; - commands: CommandResponse[]; - count: number; -} - -export interface Alert { - id: number; - username: string; - status: string; - created_at: string; - updated_at: string; -} - -// ============================================ -// 🔐 GESTION JWT ADMIN -// ============================================ - -/** - * ✅ Extraire username du JWT Admin - */ -export const extractAdminUsernameFromToken = (): string | null => { - try { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - console.log("❌ [ADMIN_JWT] Aucun token dans sessionStorage"); - return null; - } - - console.log("🔐 [ADMIN_JWT] Token trouvé, décodage..."); - - // JWT format: header.payload.signature - const parts = token.split("."); - if (parts.length !== 3) { - console.error("❌ [ADMIN_JWT] Format invalide"); - return null; - } - - // Décoder le payload (base64 -> JSON) - const base64Url = parts[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent( - atob(base64) - .split("") - .map( - (c) => - "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2), - ) - .join(""), - ); - - const payload = JSON.parse(jsonPayload); - console.log("📋 [ADMIN_JWT] Payload:", payload); - - // Extraire le username - const username = payload.username; - if (!username) { - console.error("❌ [ADMIN_JWT] Username non trouvé dans payload"); - return null; - } - - console.log("✅ [ADMIN_JWT] Username extrait:", username); - return username; - } catch (error) { - console.error("❌ [ADMIN_JWT] Erreur décodage:", error); - return null; - } -}; - -/** - * ✅ Extraire le rôle du JWT Admin - */ -export const extractAdminRoleFromToken = (): string | null => { - try { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - return null; - } - - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - - const base64Url = parts[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent( - atob(base64) - .split("") - .map( - (c) => - "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2), - ) - .join(""), - ); - - const payload = JSON.parse(jsonPayload); - const role = payload.role; - - if (!role) { - console.error("❌ [ADMIN_JWT] Role non trouvé dans payload"); - return null; - } - - console.log("✅ [ADMIN_JWT] Role extrait:", role); - return role; - } catch (error) { - console.error("❌ [ADMIN_JWT] Erreur extraction role:", error); - return null; - } -}; - -/** - * ✅ Synchroniser sessionStorage avec JWT Admin - */ -export const syncAdminUsernameFromJWT = (): string | null => { - const jwtUsername = extractAdminUsernameFromToken(); - - if (!jwtUsername) { - console.log("❌ [ADMIN_SYNC] Impossible d'extraire username du JWT"); - return null; - } - - const storedUsername = sessionStorage.getItem("admin_username"); - - // Mismatch détecté - if (storedUsername && storedUsername !== jwtUsername) { - console.warn(`⚠️ [ADMIN_SYNC] MISMATCH!`); - console.warn(` Ancien: ${storedUsername}`); - console.warn(` JWT: ${jwtUsername}`); - - sessionStorage.removeItem("admin_username"); - } - - // Toujours stocker le JWT username (source de vérité) - sessionStorage.setItem("admin_username", jwtUsername); - console.log(`✅ [ADMIN_SYNC] Username synchronisé: ${jwtUsername}`); - - return jwtUsername; -}; - -/** - * ✅ Récupérer le username admin authentifié - */ -export const getAuthenticatedAdminUsername = (): string | null => { - return extractAdminUsernameFromToken(); -}; - -/** - * ✅ Vérifier si admin est authentifié - */ -export const isAdminAuthenticated = (): boolean => { - const token = sessionStorage.getItem("admin_token"); - const username = extractAdminUsernameFromToken(); - const role = extractAdminRoleFromToken(); - - // Vérifier que c'est bien un admin - return !!(token && username && role === "admin"); -}; - -/** - * ✅ Récupérer le token admin - */ -export const getAdminAuthToken = (): string | null => { - return sessionStorage.getItem("admin_token"); -}; - -// ============================================ -// 🔐 AUTHENTIFICATION ADMIN -// ============================================ - -/** - * ✅ REGISTER ADMIN - * POST /api/v2/admin/auth/register - */ -export const registerAdmin = async ( - username: string, - password: string, -): Promise => { - try { - console.log("📝 [ADMIN_REGISTER] Appel API..."); - - const response = await fetch(`${API_URL}/admin/auth/register`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username, password }), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("❌ [ADMIN_REGISTER] Erreur API:", errorData); - return { - success: false, - message: errorData.error || "Erreur d'inscription admin", - }; - } - - const data = await response.json(); - console.log("📋 [ADMIN_REGISTER] Réponse:", data); - - // ✅ Vérifier access_token - if (!data.access_token) { - console.error("❌ [ADMIN_REGISTER] Pas de access_token"); - return { - success: false, - message: "Token non reçu du serveur", - }; - } - - // ✅ Stocker en sessionStorage avec préfixe admin_ - sessionStorage.setItem("admin_token", data.access_token); - console.log("✅ [ADMIN_REGISTER] Token admin stocké"); - - // ✅ Synchroniser username - const jwtUsername = syncAdminUsernameFromJWT(); - if (!jwtUsername) { - console.warn( - "⚠️ [ADMIN_REGISTER] Impossible de synchroniser username", - ); - return { - success: false, - message: "Erreur synchronisation JWT", - }; - } - - // ✅ Vérifier le rôle - const role = extractAdminRoleFromToken(); - if (role !== "admin") { - console.error("❌ [ADMIN_REGISTER] Rôle incorrect:", role); - sessionStorage.removeItem("admin_token"); - sessionStorage.removeItem("admin_username"); - return { - success: false, - message: "Accès admin requis", - }; - } - - console.log( - `✅ [ADMIN_REGISTER] Admin créé et connecté: ${jwtUsername}`, - ); - - return { - success: true, - message: "Inscription admin réussie", - access_token: data.access_token, - token_type: data.token_type, - expires_in: data.expires_in, - user: data.user, - }; - } catch (error) { - console.error("❌ [ADMIN_REGISTER] Erreur:", error); - return { - success: false, - message: - error instanceof Error ? error.message : "Erreur d'inscription", - }; - } -}; - -/** - * ✅ LOGIN ADMIN - * POST /api/v2/admin/auth/login - */ -export const loginAdmin = async ( - username: string, - password: string, -): Promise => { - try { - console.log("🔐 [ADMIN_LOGIN] Appel API..."); - - const response = await fetch(`${API_URL}/admin/auth/login`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username, password }), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("❌ [ADMIN_LOGIN] Erreur API:", errorData); - return { - success: false, - message: errorData.error || "Erreur de connexion admin", - }; - } - - const data = await response.json(); - console.log("📋 [ADMIN_LOGIN] Réponse:", data); - - // ✅ Vérifier access_token - if (!data.access_token) { - console.error("❌ [ADMIN_LOGIN] Pas de access_token"); - return { - success: false, - message: "Token non reçu du serveur", - }; - } - - // ✅ Stocker en sessionStorage avec préfixe admin_ - sessionStorage.setItem("admin_token", data.access_token); - console.log("✅ [ADMIN_LOGIN] Token admin stocké"); - - // ✅ Synchroniser username - const jwtUsername = syncAdminUsernameFromJWT(); - if (!jwtUsername) { - console.warn( - "⚠️ [ADMIN_LOGIN] Impossible de synchroniser username", - ); - return { - success: false, - message: "Erreur synchronisation JWT", - }; - } - - // ✅ Vérifier le rôle - const role = extractAdminRoleFromToken(); - if (role !== "admin" && role !== "livreur" && role !== "cabine") { - console.error("❌ [ADMIN_LOGIN] Rôle incorrect:", role); - sessionStorage.removeItem("admin_token"); - sessionStorage.removeItem("admin_username"); - return { - success: false, - message: "Accès admin requis", - }; - } - - console.log(`✅ [ADMIN_LOGIN] Admin connecté: ${jwtUsername}`); - - return { - success: true, - message: "Connexion admin réussie", - access_token: data.access_token, - token_type: data.token_type, - expires_in: data.expires_in, - user: data.user, - }; - } catch (error) { - console.error("❌ [ADMIN_LOGIN] Erreur:", error); - return { - success: false, - message: - error instanceof Error ? error.message : "Erreur de connexion", - }; - } -}; - -/** - * ✅ LOGOUT ADMIN - * POST /api/v2/admin/auth/logout - */ -export const logoutAdmin = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (token) { - try { - console.log("🚪 [ADMIN_LOGOUT] Appel API..."); - - await fetch(`${API_URL}/admin/auth/logout`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - console.log("✅ [ADMIN_LOGOUT] Déconnexion backend réussie"); - } catch (error) { - console.warn("⚠️ [ADMIN_LOGOUT] Erreur backend:", error); - } - } - - // Nettoyer sessionStorage - sessionStorage.removeItem("admin_token"); - sessionStorage.removeItem("admin_username"); - console.log("✅ [ADMIN_LOGOUT] sessionStorage nettoyé"); -}; - -// ============================================ -// 📊 FONCTIONS UTILITAIRES ADMIN -// ============================================ - -/** - * ✅ Vérifier si l'utilisateur actuel est admin - */ -export const checkAdminRole = (): boolean => { - const role = extractAdminRoleFromToken(); - return role === "admin"; -}; - -/** - * ✅ Récupérer les informations admin depuis le JWT - */ -export const getAdminInfo = (): { username: string; role: string } | null => { - const username = extractAdminUsernameFromToken(); - const role = extractAdminRoleFromToken(); - - if (!username || !role) { - return null; - } - - return { username, role }; -}; - -/** - * ✅ Faire une requête authentifiée admin - */ -export const adminAuthenticatedFetch = async ( - endpoint: string, - options: RequestInit = {}, -): Promise => { - const token = getAdminAuthToken(); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...options.headers, - }; - - return fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); -}; - -/** - * ✅ Récupérer tous les utilisateurs (Admin) - */ -export const getAllUsers = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🔍 [GET_ALL_USERS] Appel API..."); - - const response = await fetch(`${API_URL}/admin/protected/all/users`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_USERS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération utilisateurs"); - } - - console.log("✅ [GET_ALL_USERS] Réponse:", data); - - // Retourner le tableau d'utilisateurs - return data.users || []; // ⬅️ Ajustez selon la structure de votre réponse API - } catch (error) { - console.error("❌ [GET_ALL_USERS] Erreur fetch:", error); - throw error; - } -}; - -export const getAllClients = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - const response = await fetch(`${API_URL}/admin/protected/all/clients`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [CLIENTS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération clients"); - } - - console.log("📋 [CLIENTS] Réponse:", data); - - return data.clients; // ⬅️ retourne un tableau -}; - -export const getCommandCount = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - const response = await fetch(`${API_URL}/admin/protected/orders`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [COMMANDS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération commandes"); - } - - // Retourne juste le count - return data.count ?? 0; - } catch (error) { - console.error("❌ [COMMANDS] Erreur fetch:", error); - return 0; - } -}; - -export const getCommandCountCompleted = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - if (!token) { - throw new Error("Token admin non trouvé"); - } - - const status = "approved"; - const seenIds = new Set(); - let totalCount = 0; - try { - const response = await fetch( - `${API_URL}/admin/protected/orders?status=${status}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - const data = await response.json(); - if (!response.ok) { - console.error(`❌ [COMMANDS] Erreur pour status=${status}:`, data); - } - if (data.commands && Array.isArray(data.commands)) { - data.commands.forEach((cmd: CommandResponse) => { - if (!seenIds.has(cmd.id)) { - seenIds.add(cmd.id); - totalCount++; - } - }); - } - - return totalCount; - } catch (error) { - console.error("❌ [COMMANDS] Erreur fetch:", error); - return 0; - } -}; - -export const getCommandCountInRoute = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - if (!token) { - throw new Error("Token admin non trouvé"); - } - - const status = "en_route"; - const seenIds = new Set(); - let totalCount = 0; - try { - const response = await fetch( - `${API_URL}/admin/protected/orders?status=${status}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - const data = await response.json(); - if (!response.ok) { - console.error(`❌ [COMMANDS] Erreur pour status=${status}:`, data); - } - if (data.commands && Array.isArray(data.commands)) { - data.commands.forEach((cmd: CommandResponse) => { - if (!seenIds.has(cmd.id)) { - seenIds.add(cmd.id); - totalCount++; - } - }); - } - - return totalCount; - } catch (error) { - console.error("❌ [COMMANDS] Erreur fetch:", error); - return 0; - } -}; - -export const getCommandCountByStatus = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - const status = ["pending"]; - let totalCount = 0; - const seenIds = new Set(); - - try { - for (const s of status) { - const response = await fetch( - `${API_URL}/admin/protected/orders?status=${s}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error(`❌ [COMMANDS] Erreur pour status=${s}:`, data); - continue; - } - - if (data.commands && Array.isArray(data.commands)) { - data.commands.forEach((cmd: CommandResponse) => { - if (!seenIds.has(cmd.id)) { - seenIds.add(cmd.id); - totalCount++; - } - }); - } - } - - console.log(`✅ [COMMANDS] Total commandes en attente: ${totalCount}`); - return totalCount; - } catch (error) { - console.error("❌ [COMMANDS] Erreur fetch:", error); - return 0; - } -}; - -export const getAllCommands = async (status?: string, username?: string) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - let url = `${API_URL}/admin/protected/orders`; - const params = new URLSearchParams(); - - if (status) params.append("status", status); - if (username) params.append("username", username); - - if (params.toString()) { - url += `?${params.toString()}`; - } - - console.log("🔍 [GET_ALL_COMMANDS] Appel:", url); - - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_COMMANDS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération commandes"); - } - - console.log("✅ [GET_ALL_COMMANDS] Réponse:", data); - - return { - success: true, - commands: data.commands || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_ALL_COMMANDS] Erreur fetch:", error); - return { - success: false, - commands: [], - count: 0, - }; - } -}; - -/** - * ✅ Récupérer une commande par ID - */ -export const getCommandByID = async (commandId: number) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🔍 [GET_COMMAND_BY_ID] Appel:", commandId); - - const response = await fetch( - `${API_URL}/admin/protected/orders/${commandId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_COMMAND_BY_ID] Erreur API:", data); - throw new Error(data.error || "Erreur récupération commande"); - } - - console.log("✅ [GET_COMMAND_BY_ID] Réponse:", data); - - return { - success: true, - command: data.command, - }; - } catch (error) { - console.error("❌ [GET_COMMAND_BY_ID] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Mettre à jour le statut d'une commande - */ -export const updateCommandStatus = async ( - commandId: number, - newStatus: string, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📝 [UPDATE_STATUS] Appel:", commandId, newStatus); - - const response = await fetch( - `${API_URL}/admin/protected/orders/${commandId}/status`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ status: newStatus }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_STATUS] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour statut"); - } - - console.log("✅ [UPDATE_STATUS] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_STATUS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Assigner un livreur à une commande - */ -export const assignDeliveryPerson = async ( - commandId: number, - deliveryPersonUsername: string, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log( - "👤 [ASSIGN_DELIVERY] Appel:", - commandId, - deliveryPersonUsername, - ); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${deliveryPersonUsername}/assign/${commandId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [ASSIGN_DELIVERY] Erreur API:", data); - throw new Error(data.error || "Erreur assignation livreur"); - } - - console.log("✅ [ASSIGN_DELIVERY] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [ASSIGN_DELIVERY] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer les livreurs disponibles - */ -export const getAvailableDeliveryPersons = async () => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🔍 [GET_DELIVERY_PERSONS] Appel"); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERY_PERSONS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération livreurs"); - } - - console.log("✅ [GET_DELIVERY_PERSONS] Réponse:", data); - - return { - success: true, - livreurs: data.livreurs || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_DELIVERY_PERSONS] Erreur fetch:", error); - return { - success: false, - livreurs: [], - count: 0, - }; - } -}; - -/** - * ✅ Récupérer tous les livreurs avec détails complets et stats - * GET /api/v2/admin/protected/delivery-persons - * Enrichit automatiquement avec les détails et stats de chaque livreur - */ -export const getAllDeliveryPersonsWithDetails = async () => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - /** - * ✅ Helper: Parser le statut qui peut être une chaîne JSON - */ - const parseStatus = (status: any): "available" | "busy" | "offline" => { - if (!status) return "offline"; - - // Si c'est déjà une valeur valide - if ( - status === "available" || - status === "busy" || - status === "offline" - ) { - return status; - } - - // Si c'est une chaîne JSON - if (typeof status === "string" && status.startsWith("{")) { - try { - const statusObj = JSON.parse(status); - const parsedStatus = statusObj.status; - - if ( - parsedStatus === "available" || - parsedStatus === "busy" || - parsedStatus === "offline" - ) { - console.log( - `🔄 [PARSE_STATUS] Statut parsé: "${parsedStatus}"`, - ); - return parsedStatus; - } - } catch (e) { - console.warn(`⚠️ [PARSE_STATUS] Impossible de parser:`, status); - } - } - - // Par défaut - return "offline"; - }; - - try { - console.log("🔍 [GET_ALL_DELIVERY_PERSONS] Appel"); - - // Récupérer la liste de base - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_DELIVERY_PERSONS] Erreur API:", data); - return { - success: false, - deliveryPersons: [], - count: 0, - stats: { - total: 0, - available: 0, - busy: 0, - offline: 0, - active_deliveries: 0, - }, - }; - } - - console.log("✅ [GET_ALL_DELIVERY_PERSONS] Réponse de base:", data); - - const livreurs = data.livreurs || []; - - // Enrichir chaque livreur avec ses détails et stats - const enrichedDeliveryPersons = await Promise.all( - livreurs.map(async (livreur: any) => { - try { - // Récupérer les détails du livreur - const details = await getDeliveryPersonDetails( - livreur.username, - ); - - // Récupérer les stats du livreur - const stats = await getDeliveryPersonStats( - livreur.username, - ); - - // ✅ Parser le statut (gère les cas où c'est du JSON) - const parsedStatus = parseStatus(details.status); - - return { - id: livreur.id, - username: livreur.username, - nom: livreur.nom || "", - prenom: livreur.prenom || "", - telephone: livreur.telephone || "", - status: parsedStatus, - location: { - latitude: details.location?.latitude || 0, - longitude: details.location?.longitude || 0, - last_update: details.location?.last_update - ? new Date( - details.location.last_update * 1000, - ).toISOString() - : new Date().toISOString(), - is_recent: details.location?.is_recent || false, - }, - stats: { - total_deliveries: stats.total_deliveries || 0, - completed_today: stats.completed_deliveries || 0, - queue_size: details.queue_size || 0, - current_command: details.current_command || null, - }, - }; - } catch (error) { - console.warn( - `⚠️ [GET_ALL_DELIVERY_PERSONS] Erreur enrichissement ${livreur.username}:`, - error, - ); - - // Retourner des données de base en cas d'erreur - return { - id: livreur.id, - username: livreur.username, - nom: livreur.nom || "", - prenom: livreur.prenom || "", - telephone: livreur.telephone || "", - status: "offline" as const, - location: { - latitude: 0, - longitude: 0, - last_update: new Date().toISOString(), - is_recent: false, - }, - stats: { - total_deliveries: 0, - completed_today: 0, - queue_size: 0, - current_command: null, - }, - }; - } - }), - ); - - // Calculer les stats globales - const globalStats = { - total: enrichedDeliveryPersons.length, - available: enrichedDeliveryPersons.filter( - (d) => d.status === "available", - ).length, - busy: enrichedDeliveryPersons.filter((d) => d.status === "busy") - .length, - offline: enrichedDeliveryPersons.filter( - (d) => d.status === "offline", - ).length, - active_deliveries: enrichedDeliveryPersons.filter( - (d) => d.stats.current_command !== null, - ).length, - }; - - console.log( - "✅ [GET_ALL_DELIVERY_PERSONS] Données enrichies:", - enrichedDeliveryPersons.length, - "livreurs", - ); - console.log( - "📊 [GET_ALL_DELIVERY_PERSONS] Stats globales:", - globalStats, - ); - - return { - success: true, - deliveryPersons: enrichedDeliveryPersons, - count: enrichedDeliveryPersons.length, - stats: globalStats, - }; - } catch (error) { - console.error("❌ [GET_ALL_DELIVERY_PERSONS] Erreur fetch:", error); - return { - success: false, - deliveryPersons: [], - count: 0, - stats: { - total: 0, - available: 0, - busy: 0, - offline: 0, - active_deliveries: 0, - }, - }; - } -}; - -/** - * ✅ Valider/Approuver une commande (Admin) - */ -export const validateCommand = async (commandId: number) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("✅ [VALIDATE_COMMAND] Appel:", commandId); - - const response = await fetch( - `${API_URL}/admin/protected/orders/${commandId}/force-validate`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [VALIDATE_COMMAND] Erreur API:", data); - throw new Error(data.error || "Erreur validation commande"); - } - - console.log("✅ [VALIDATE_COMMAND] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [VALIDATE_COMMAND] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Mettre à jour l'adresse de livraison - */ -export const updateCommandAddress = async ( - commandId: number, - newAddress: string, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📝 [UPDATE_ADDRESS] Appel:", commandId, newAddress); - - const response = await fetch( - `${API_URL}/admin/protected/orders/${commandId}/address`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ delivery_address: newAddress }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_ADDRESS] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour adresse"); - } - - console.log("✅ [UPDATE_ADDRESS] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_ADDRESS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer la position GPS du livreur assigné à une commande (Admin) - * GET /api/v2/admin/protected/commands/:id/deliveryman/location - */ -export const getDeliverymanLocationForCommand = async (commandId: number) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log( - "📍 [GET_DELIVERYMAN_LOCATION] Appel pour commande:", - commandId, - ); - - const response = await fetch( - `${API_URL}/admin/protected/commands/${commandId}/deliveryman/location`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERYMAN_LOCATION] Erreur API:", data); - - // Si pas de livreur assigné ou pas de position, retourner info utile - if (response.status === 404) { - return { - success: false, - error: data.error, - message: data.message, - command_info: data.command_info || null, - }; - } - - throw new Error( - data.error || "Erreur récupération position livreur", - ); - } - - console.log("✅ [GET_DELIVERYMAN_LOCATION] Réponse:", data); - - return { - success: true, - data: { - command_id: data.data.command_id, - client: data.data.client, - command_status: data.data.command_status, - deliveryman: { - username: data.data.deliveryman.username, - status: data.data.deliveryman.status, - current_command: data.data.deliveryman.current_command, - queue_size: data.data.deliveryman.queue_size, - location: { - latitude: data.data.deliveryman.location.latitude, - longitude: data.data.deliveryman.location.longitude, - last_update: data.data.deliveryman.location.last_update, - last_update_ago: - data.data.deliveryman.location.last_update_ago, - is_recent: data.data.deliveryman.location.is_recent, - }, - }, - eta: { - minutes: data.data.eta.minutes, - has_eta: data.data.eta.has_eta, - set_at: data.data.eta.set_at, - }, - }, - requested_by: data.requested_by, - }; - } catch (error) { - console.error("❌ [GET_DELIVERYMAN_LOCATION] Erreur fetch:", error); - throw error; - } -}; - -export const updateClientByAdmin = async ( - clientId: number, - updates: { - username?: string; - password?: string; - nom?: string; - prenom?: string; - telephone?: string; - command?: number; - point?: number; - amende?: number; - }, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📝 [UPDATE_CLIENT_ADMIN] Appel:", clientId, updates); - - const response = await fetch( - `${API_URL}/admin/protected/clients/${clientId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(updates), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_CLIENT_ADMIN] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour client"); - } - - console.log("✅ [UPDATE_CLIENT_ADMIN] Réponse:", data); - - return { - success: true, - message: data.message, - client: data.client, - }; - } catch (error) { - console.error("❌ [UPDATE_CLIENT_ADMIN] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Mettre à jour un profil USER (Admin/Cabine/Livreur) par Admin - * PUT /api/v2/admin/protected/users/:id - * - * @param userId - ID de l'utilisateur à modifier - * @param updates - Champs à mettre à jour - * @returns Réponse avec l'utilisateur mis à jour - * - * @example - * // Modifier le username - * await updateUserByAdmin(1, { username: "nouveau_username" }); - * - * // Modifier le mot de passe - * await updateUserByAdmin(1, { password: "nouveau_password_secure" }); - * - * // Changer le rôle - * await updateUserByAdmin(1, { role: "livreur" }); - */ -export const updateUserByAdmin = async ( - userId: number, - updates: { - username?: string; - password?: string; - role?: "admin" | "cabine" | "livreur"; - }, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📝 [UPDATE_USER_ADMIN] Appel:", userId, updates); - - const response = await fetch( - `${API_URL}/admin/protected/users/${userId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(updates), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_USER_ADMIN] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour utilisateur"); - } - - console.log("✅ [UPDATE_USER_ADMIN] Réponse:", data); - - return { - success: true, - message: data.message, - user: data.user, - }; - } catch (error) { - console.error("❌ [UPDATE_USER_ADMIN] Erreur fetch:", error); - throw error; - } -}; - -export const getAllProductsAdmin = async (): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📦 [GET_ALL_PRODUCTS_ADMIN] Appel API..."); - - const response = await fetch(`${API_URL}/admin/protected/products`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_PRODUCTS_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur récupération produits", - }; - } - - console.log("✅ [GET_ALL_PRODUCTS_ADMIN] Réponse:", data); - - return { - success: true, - data: data.data || [], - count: data.count || 0, - message: data.message, - }; - } catch (error) { - console.error("❌ [GET_ALL_PRODUCTS_ADMIN] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -/** - * ✅ Récupérer un produit par ID (Admin) - * GET /api/v2/admin/protected/products/:id - */ -export const getProductByIdAdmin = async ( - productId: number, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📦 [GET_PRODUCT_BY_ID_ADMIN] Appel API:", productId); - - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_PRODUCT_BY_ID_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur récupération produit", - }; - } - - console.log("✅ [GET_PRODUCT_BY_ID_ADMIN] Réponse:", data); - - return { - success: true, - data: data.data, - message: data.message, - }; - } catch (error) { - console.error("❌ [GET_PRODUCT_BY_ID_ADMIN] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -// ============================================ -// ➕ CRÉATION DE PRODUIT -// ============================================ - -/** - * ✅ Créer un nouveau produit avec médias (Admin) - * POST /api/v2/admin/protected/products - * - * @param productData - Données du produit - * @param mediaFiles - Fichiers médias (images/vidéos) - * - * IMPORTANT: Envoie en multipart/form-data - */ -export const createProductAdmin = async ( - productData: CreateProductData, - mediaFiles?: File[], -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("➕ [CREATE_PRODUCT_ADMIN] Préparation FormData..."); - console.log("📋 [CREATE_PRODUCT_ADMIN] Données:", productData); - - // ✅ Créer FormData pour multipart/form-data - const formData = new FormData(); - - // ✅ Ajouter les champs basiques - formData.append("name", productData.name); - formData.append("category", productData.category); - formData.append("description", productData.description); - formData.append("stock", productData.stock.toString()); - - console.log("📝 [CREATE_PRODUCT_ADMIN] Champs basiques ajoutés"); - - // ✅ Ajouter les prix - productData.prices.forEach((price, index) => { - formData.append( - `prices[${index}][quantity]`, - price.quantity.toString(), - ); - formData.append(`prices[${index}][price]`, price.price.toString()); - console.log( - `💰 [CREATE_PRODUCT_ADMIN] Prix[${index}]: ${price.quantity}g = ${price.price}€`, - ); - }); - - // ✅ Ajouter les fichiers médias - if (mediaFiles && mediaFiles.length > 0) { - console.log( - `📁 [CREATE_PRODUCT_ADMIN] Ajout de ${mediaFiles.length} fichiers médias`, - ); - - mediaFiles.forEach((file, index) => { - formData.append("media", file); - console.log( - ` 📄 [${index + 1}] ${file.name} (${file.type}, ${file.size} bytes)`, - ); - }); - } else { - console.log("⚠️ [CREATE_PRODUCT_ADMIN] Aucun média à ajouter"); - } - - // ✅ Log du contenu FormData (debug) - console.log("📦 [CREATE_PRODUCT_ADMIN] Contenu FormData:"); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log(` ${key}: File(${value.name})`); - } else { - console.log(` ${key}: ${value}`); - } - } - - console.log("🚀 [CREATE_PRODUCT_ADMIN] Envoi requête..."); - - // ✅ Envoyer la requête (sans Content-Type - le navigateur le gère automatiquement) - const response = await fetch(`${API_URL}/admin/protected/products`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [CREATE_PRODUCT_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur création produit", - }; - } - - console.log("✅ [CREATE_PRODUCT_ADMIN] Produit créé:", data); - - return { - success: true, - product: data.product, - message: data.message, - }; - } catch (error) { - console.error("❌ [CREATE_PRODUCT_ADMIN] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -// ============================================ -// ✏️ MODIFICATION DE PRODUIT -// ============================================ - -/** - * ✅ Mettre à jour un produit (Admin) - * PUT /api/v2/admin/protected/products/:id - */ -export const updateProductAdmin = async ( - productId: number, - updates: Partial, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("✏️ [UPDATE_PRODUCT_ADMIN] Appel API:", productId, updates); - - const response = await fetch( - `${API_URL}/admin/protected/products/update/${productId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(updates), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_PRODUCT_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur mise à jour produit", - }; - } - - console.log("✅ [UPDATE_PRODUCT_ADMIN] Produit mis à jour:", data); - - return { - success: true, - product: data.product, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_PRODUCT_ADMIN] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -// ============================================ -// 🗑️ SUPPRESSION DE PRODUIT -// ============================================ - -/** - * ✅ Supprimer un produit (Admin) - * DELETE /api/v2/admin/protected/products/:id - */ -export const deleteProductAdmin = async ( - productId: number, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🗑️ [DELETE_PRODUCT_ADMIN] Appel API:", productId); - - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_PRODUCT_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur suppression produit", - }; - } - - console.log("✅ [DELETE_PRODUCT_ADMIN] Produit supprimé:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [DELETE_PRODUCT_ADMIN] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -// ============================================ -// 📊 GESTION DES PRIX -// ============================================ - -/** - * ✅ Récupérer les prix d'un produit - * GET /api/v2/admin/protected/products/:id/prices - */ -export const getProductPricesAdmin = async (productId: number) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}/prices`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_PRICES] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur récupération prix", - }; - } - - return { - success: true, - data: data.data || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_PRICES] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -/** - * ✅ Ajouter un prix à un produit - * POST /api/v2/admin/protected/products/:id/prices - */ -export const addProductPriceAdmin = async ( - productId: number, - quantity: number, - price: number, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}/prices`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ quantity, price }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [ADD_PRICE] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur ajout prix", - }; - } - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [ADD_PRICE] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -// ============================================ -// 🎬 GESTION DES MÉDIAS -// ============================================ - -/** - * ✅ Uploader un média pour un produit existant - * POST /api/v2/admin/protected/products/:id/media - */ -export const uploadProductMediaAdmin = async ( - productId: number, - file: File, - type: "image" | "video", -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📤 [UPLOAD_MEDIA] Upload fichier:", file.name); - - const formData = new FormData(); - formData.append("file", file); - formData.append("type", type); - - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}/media`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPLOAD_MEDIA] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur upload média", - }; - } - - console.log("✅ [UPLOAD_MEDIA] Média uploadé:", data); - - return { - success: true, - media: data.media, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPLOAD_MEDIA] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -/** - * ✅ Supprimer un média - * DELETE /api/v2/admin/protected/products/:productId/media/:mediaId - */ -export const deleteProductMediaAdmin = async ( - productId: number, - mediaId: number, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🗑️ [DELETE_MEDIA] Suppression média:", mediaId); - - const response = await fetch( - `${API_URL}/admin/protected/products/${productId}/media/${mediaId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_MEDIA] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur suppression média", - }; - } - - console.log("✅ [DELETE_MEDIA] Média supprimé"); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [DELETE_MEDIA] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -export const getDeliveryPersonDetails = async ( - username: string, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("👤 [GET_DELIVERY_DETAILS] Appel pour:", username); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERY_DETAILS] Erreur API:", data); - throw new Error( - data.error || "Erreur récupération détails livreur", - ); - } - - console.log("✅ [GET_DELIVERY_DETAILS] Réponse:", data); - - return data.deliveryman; - } catch (error) { - console.error("❌ [GET_DELIVERY_DETAILS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Modifier le statut d'un livreur (Admin) - * PUT /api/v2/admin/protected/delivery-persons/:username/status - */ -export const updateDeliveryPersonStatusAdmin = async ( - username: string, - status: "available" | "busy" | "offline", -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📝 [UPDATE_DELIVERY_STATUS] Appel:", username, status); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}/status`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ status }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour statut livreur"); - } - - console.log("✅ [UPDATE_DELIVERY_STATUS] Réponse:", data); - - return { - success: true, - message: data.message, - deliveryman: data.deliveryman, - }; - } catch (error) { - console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer les statistiques d'un livreur - * GET /api/v2/admin/protected/delivery-persons/:username/stats - */ -export const getDeliveryPersonStats = async ( - username: string, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📊 [GET_DELIVERY_STATS] Appel pour:", username); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}/stats`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERY_STATS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération stats livreur"); - } - - console.log("✅ [GET_DELIVERY_STATS] Réponse:", data); - - return data.stats; - } catch (error) { - console.error("❌ [GET_DELIVERY_STATS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer l'historique des livraisons d'un livreur - * GET /api/v2/admin/protected/delivery-persons/:username/history - */ -export const getDeliveryPersonHistory = async ( - username: string, - limit?: number, - offset?: number, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - let url = `${API_URL}/admin/protected/delivery-persons/${username}/history`; - const params = new URLSearchParams(); - - if (limit) params.append("limit", limit.toString()); - if (offset) params.append("offset", offset.toString()); - - if (params.toString()) { - url += `?${params.toString()}`; - } - - console.log("📜 [GET_DELIVERY_HISTORY] Appel:", url); - - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERY_HISTORY] Erreur API:", data); - throw new Error(data.error || "Erreur récupération historique"); - } - - console.log("✅ [GET_DELIVERY_HISTORY] Réponse:", data); - - return { - success: true, - history: data.history || [], - count: data.count || 0, - total: data.total || 0, - }; - } catch (error) { - console.error("❌ [GET_DELIVERY_HISTORY] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Modifier la position GPS d'un livreur (Admin) - * PUT /api/v2/admin/protected/delivery-persons/:username/location - */ -export const updateDeliveryPersonLocationAdmin = async ( - username: string, - latitude: number, - longitude: number, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("📍 [UPDATE_DELIVERY_LOCATION] Appel:", username, { - latitude, - longitude, - }); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}/location`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ latitude, longitude }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_DELIVERY_LOCATION] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour position"); - } - - console.log("✅ [UPDATE_DELIVERY_LOCATION] Réponse:", data); - - return { - success: true, - message: data.message, - location: data.location, - }; - } catch (error) { - console.error("❌ [UPDATE_DELIVERY_LOCATION] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Retirer une commande de la queue d'un livreur - * DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id - */ -export const removeCommandFromQueue = async ( - username: string, - commandId: number, -) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🗑️ [REMOVE_FROM_QUEUE] Appel:", username, commandId); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}/queue/${commandId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [REMOVE_FROM_QUEUE] Erreur API:", data); - throw new Error(data.error || "Erreur suppression de la queue"); - } - - console.log("✅ [REMOVE_FROM_QUEUE] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [REMOVE_FROM_QUEUE] Erreur fetch:", error); - throw error; - } -}; - -export const getDeliveryPersonMapLinks = async (username: string) => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🗺️ [GET_MAP_LINKS] Appel pour:", username); - - const response = await fetch( - `${API_URL}/admin/protected/delivery-persons/${username}/map-links`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MAP_LINKS] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur récupération liens GPS", - }; - } - - console.log("✅ [GET_MAP_LINKS] Réponse:", data); - - return { - success: true, - deliveryman: data.deliveryman, - location: data.location, - map_links: data.map_links, - }; - } catch (error) { - console.error("❌ [GET_MAP_LINKS] Erreur fetch:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } -}; - -export const deleteUserAdmin = async ( - userId: number, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log( - "🗑️ [DELETE_USER_ADMIN] Tentative de suppression de l'utilisateur ID:", - userId, - ); - - const response = await fetch( - `${API_URL}/admin/protected/users/${userId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_USER_ADMIN] Erreur API:", data); - return { - success: false, - error: - data.error || - "Erreur lors de la suppression de l'utilisateur", - message: data.message, - }; - } - - console.log( - "✅ [DELETE_USER_ADMIN] Utilisateur supprimé avec succès:", - data, - ); - - return { - success: true, - message: data.message || "Utilisateur supprimé avec succès", - }; - } catch (error) { - console.error("❌ [DELETE_USER_ADMIN] Erreur fetch:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erreur réseau inconnue", - }; - } -}; - -export const deleteClientAdmin = async ( - clientId: number, -): Promise => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log( - "🗑️ [DELETE_CLIENT_ADMIN] Tentative de suppression du client ID:", - clientId, - ); - - const response = await fetch( - `${API_URL}/admin/protected/clients/${clientId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_CLIENT_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur lors de la suppression du client", - message: data.message, - }; - } - - console.log( - "✅ [DELETE_CLIENT_ADMIN] Client supprimé avec succès:", - data, - ); - - return { - success: true, - message: data.message || "Client supprimé avec succès", - }; - } catch (error) { - console.error("❌ [DELETE_CLIENT_ADMIN] Erreur fetch:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erreur réseau inconnue", - }; - } -}; - -export const deleteAlertAdmin = async ( - alertId: number, -): Promise<{ - success: boolean; - message?: string; - error?: string; -}> => { - const token = sessionStorage.getItem("admin_token"); - - if (!token) { - throw new Error("Token admin non trouvé"); - } - - try { - console.log("🗑️ [DELETE_ALERT_ADMIN] Suppression alerte:", alertId); - - const response = await fetch( - `${API_URL}/admin/protected/delete/alerts/${alertId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_ALERT_ADMIN] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur suppression alerte", - }; - } - - console.log("✅ [DELETE_ALERT_ADMIN] Alerte supprimée avec succès"); - - return { - success: true, - message: data.message || "Alerte supprimée avec succès", - }; - } catch (error) { - console.error("❌ [DELETE_ALERT_ADMIN] Erreur fetch:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erreur réseau inconnue", - }; - } -}; - -export const CreateUser = async ( - username: string, - password: string, - role: string, -) => { - try { - const token = sessionStorage.getItem("admin_token"); - const response = await fetch(`${API_URL}/admin/protected/users`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ username, password, role }), - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [CREATE_USER] Erreur API:", data); - return { - success: false, - error: data.error || "Erreur création utilisateur", - }; - } - - console.log("✅ [CREATE_USER] Utilisateur créé avec succès"); - - return { - success: true, - message: data.message || "Utilisateur créé avec succès", - }; - } catch (error) { - console.error("❌ [CREATE_USER] Erreur fetch:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erreur réseau inconnue", - }; - } -}; - -// ============================================ -// 🔄 EXPORT PAR DÉFAUT -// ============================================ - -export default { - // Auth - registerAdmin, - loginAdmin, - logoutAdmin, - CreateUser, - - // JWT Utils - extractAdminUsernameFromToken, - extractAdminRoleFromToken, - syncAdminUsernameFromJWT, - getAuthenticatedAdminUsername, - isAdminAuthenticated, - getAdminAuthToken, - - // Users - getAllUsers, - getAllClients, - - // Commands - getAllCommands, - getCommandByID, - getCommandCount, - getCommandCountCompleted, - getCommandCountInRoute, - getCommandCountByStatus, - updateCommandStatus, - assignDeliveryPerson, - getAvailableDeliveryPersons, - validateCommand, - updateCommandAddress, - getDeliverymanLocationForCommand, // ⭐ NOUVEAU - - // Utils - checkAdminRole, - getAdminInfo, - adminAuthenticatedFetch, - - // Update profile - updateClientByAdmin, - updateUserByAdmin, - - // Produits - getAllProductsAdmin, - getProductByIdAdmin, - createProductAdmin, - updateProductAdmin, - deleteProductAdmin, - - // Prix - getProductPricesAdmin, - addProductPriceAdmin, - - // Médias - uploadProductMediaAdmin, - deleteProductMediaAdmin, - - // Livreurs - ⭐ NOUVEAU - getAllDeliveryPersonsWithDetails, - getDeliveryPersonDetails, - getDeliveryPersonStats, - getDeliveryPersonHistory, - updateDeliveryPersonStatusAdmin, - updateDeliveryPersonLocationAdmin, - removeCommandFromQueue, - getDeliveryPersonMapLinks, - - deleteClientAdmin, - deleteUserAdmin, - - deleteAlertAdmin, -}; diff --git a/frontend-prep/src/api/api_admin_types.ts b/frontend-prep/src/api/api_admin_types.ts deleted file mode 100644 index 5e63450a..00000000 --- a/frontend-prep/src/api/api_admin_types.ts +++ /dev/null @@ -1,363 +0,0 @@ -export interface AdminLogin { - username: string; - password: string; -} - -export interface ApiResponse { - success: boolean; - message?: string; - error?: string; - access_token?: string; - token_type?: string; - expires_in?: number; - user?: AdminResponse; - [key: string]: any; -} - -export interface AdminResponse { - id: number; - username: string; - role: string; -} - -/** - * ✅ Deliveryman Location Response - */ -export interface DeliverymanLocation { - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; -} - -export interface DeliverymanInfo { - username: string; - status: string; - current_command: number; - queue_size: number; - location: DeliverymanLocation; -} - -export interface CommandETA { - minutes: number; - has_eta: boolean; - set_at: number; -} - -export interface DeliverymanLocationResponse { - success: boolean; - data?: { - command_id: number; - client: string; - command_status: string; - deliveryman: DeliverymanInfo; - eta: CommandETA; - }; - requested_by?: { - username: string; - role: string; - }; - error?: string; - message?: string; - command_info?: { - command_id: number; - client: string; - deliveryman?: string; - status: string; - }; -} - -export interface ProductPrice { - id?: number; - product_id?: number; - quantity: number; - price: number; - created_at?: string; -} - -export interface Media { - id?: number; - product_id?: number; - type: string; - url: string; -} - -export interface Product { - id?: number; - name: string; - category: string; - description: string; - stock: number; - prices: ProductPrice[]; - media?: Media[]; - created_at?: string; - updated_at?: string; -} - -export interface ProductResponse { - success: boolean; - message?: string; - product?: Product; - data?: Product; - error?: string; -} - -export interface ProductListResponse { - success: boolean; - data?: Product[]; - count?: number; - message?: string; - error?: string; -} - -export interface CreateProductData { - name: string; - category: string; - description: string; - stock: number; - prices: ProductPrice[]; - media?: File[]; -} - -/** - * ✅ Informations détaillées d'un livreur - */ -export interface DeliveryPersonDetails { - id: number; - username: string; - role: string; - status: "available" | "busy" | "offline"; - current_command?: number | null; - queue_size: number; - total_deliveries?: number; - completed_deliveries?: number; - pending_deliveries?: number; - location?: { - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; - }; - created_at?: string; - updated_at?: string; -} - -/** - * ✅ Statistiques d'un livreur - */ -export interface DeliveryPersonStats { - username: string; - total_deliveries: number; - completed_deliveries: number; - cancelled_deliveries: number; - pending_deliveries: number; - in_progress_deliveries: number; - average_delivery_time?: number; // en minutes - success_rate?: number; // en pourcentage - total_distance?: number; // en km - current_queue_size: number; - last_delivery_date?: string; - status: string; -} - -/** - * ✅ Historique des livraisons d'un livreur - */ -export interface DeliveryHistory { - command_id: number; - client: string; - status: string; - adresse: string; - total_prix: number; - assigned_at: string; - completed_at?: string; - delivery_time?: number; // en minutes - distance?: number; // en km -} - -/** - * ✅ Position GPS d'un livreur - */ -export interface DeliveryPersonLocation { - username: string; - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; - status: string; -} - -export interface DeliverymanLocation { - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; -} - -/** - * ✅ Statistiques d'un livreur - */ -export interface DeliveryPersonStats { - username: string; - total_deliveries: number; - completed_deliveries: number; - cancelled_deliveries: number; - pending_deliveries: number; - in_progress_deliveries: number; - average_delivery_time?: number; // en minutes - success_rate?: number; // en pourcentage - total_distance?: number; // en km - current_queue_size: number; - last_delivery_date?: string; - status: string; -} - -/** - * ✅ Historique des livraisons d'un livreur - */ -export interface DeliveryHistory { - command_id: number; - client: string; - status: string; - adresse: string; - total_prix: number; - assigned_at: string; - completed_at?: string; - delivery_time?: number; // en minutes - distance?: number; // en km -} - -/** - * ✅ Position GPS d'un livreur - */ -export interface DeliveryPersonLocation { - username: string; - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; - status: string; -} - -/** - * ✅ Réponse position livreur pour une commande - */ -export interface DeliverymanLocationResponse { - success: boolean; - data?: { - command_id: number; - client: string; - command_status: string; - deliveryman: DeliverymanInfo; - eta: CommandETA; - }; - requested_by?: { - username: string; - role: string; - }; - error?: string; - message?: string; - command_info?: { - command_id: number; - client: string; - deliveryman?: string; - status: string; - }; -} - -/** - * ✅ Liste des livreurs disponibles - */ -export interface DeliveryPersonsListResponse { - success: boolean; - livreurs: DeliveryPersonDetails[]; - count: number; -} - -// ============================================ -// 📦 INTERFACES LIVREURS - CABINE & ADMIN -// ============================================ - -/** - * ✅ Livreur avec détails complets (pour liste) - */ -export interface DeliveryPerson { - id: number; - username: string; - nom?: string; - prenom?: string; - telephone?: string; - status: "available" | "busy" | "offline"; - location: { - latitude: number; - longitude: number; - last_update: string; - is_recent: boolean; - }; - stats: { - total_deliveries: number; - completed_today: number; - queue_size: number; - current_command: number | null; - }; -} - -/** - * ✅ Stats globales des livreurs - */ -export interface DeliveryPersonsStats { - total: number; - available: number; - busy: number; - offline: number; - active_deliveries: number; -} - -/** - * ✅ Réponse API getAllDeliveryPersonsWithDetails - */ -export interface AllDeliveryPersonsResponse { - success: boolean; - deliveryPersons: DeliveryPerson[]; - count: number; - stats: DeliveryPersonsStats; - error?: string; -} - -/** - * ✅ Liens de navigation GPS - */ -export interface MapLinks { - google_maps: string; - waze: string; - apple_maps: string; - openstreetmap: string; -} - -/** - * ✅ Réponse API getDeliveryPersonMapLinks - */ -export interface MapLinksResponse { - success: boolean; - deliveryman?: string; - location?: { - latitude: number; - longitude: number; - last_update?: number; - is_recent: boolean; - }; - map_links?: MapLinks; - error?: string; - message?: string; -} - -export interface DeleteResponse { - success: boolean; - message?: string; - error?: string; -} diff --git a/frontend-prep/src/api/api_cabine.ts b/frontend-prep/src/api/api_cabine.ts deleted file mode 100644 index 4b97222b..00000000 --- a/frontend-prep/src/api/api_cabine.ts +++ /dev/null @@ -1,1115 +0,0 @@ -// ============================================ -// api/api_cabine.ts - CABINE API ONLY -// ============================================ -// ✅ Gestion complète des fonctions cabine -// ✅ Utilise /api/v1/cabine/* endpoints uniquement -// ✅ sessionStorage pour la persistance - -const API_URL = "/api/v1/cabine"; -import type { - DeliveryPerson, - DeliveryPersonsStats, - AllDeliveryPersonsResponse, - MapLinksResponse, -} from "./api_admin_types"; -// ============================================ -// 🔐 TYPES - CABINE -// ============================================ - -export interface DeliveryOrder { - id: number; - username: string; - status: string; - adresse: string; - total_prix: number; - livreur_assign?: string | null; - created_at: string; - updated_at: string; - items?: OrderItem[]; - client_info?: { - username: string; - nom?: string; - prenom?: string; - telephone?: string; - points?: number; - amende?: number; - }; -} - -export interface OrderItem { - id: number; - command_id: number; - product_id: number; - product_name: string; - quantity: number; - price: number; - status: string; - created_at: string; -} - -export interface PenaltyInfo { - client_username: string; - current_amende: number; - cancellations_count: number; - next_penalty: number; - penalty_scale: { - "1st": number; - "2nd": number; - "3rd": number; - "4th+": number; - }; -} - -export interface ClientWithPenalty { - username: string; - nom?: string; - prenom?: string; - telephone?: string; - amende: number; - point: number; - cancellations_count: number; - last_penalty_reason?: string; -} - -export interface PenaltiesStats { - total_clients_with_penalties: number; - total_penalties_amount: number; - average_penalty: number; - total_cancellations: number; -} - -export interface DeliverymanLocation { - latitude: number; - longitude: number; - last_update: number; - last_update_ago: number; - is_recent: boolean; -} - -export interface MapLinks { - google_maps: string; - waze: string; - apple_maps: string; - openstreetmap: string; -} - -export interface Alert { - id: number; - username: string; - status: string; - created_at: string; - updated_at: string; -} -// ============================================ -// 🔐 GESTION JWT CABINE -// ============================================ - -/** - * ✅ Récupérer le token cabine - */ -export const getCabineAuthToken = (): string | null => { - return sessionStorage.getItem("admin_token"); // Utilise le même token que admin -}; - -/** - * ✅ Vérifier si cabine est authentifié - */ -export const isCabineAuthenticated = (): boolean => { - const token = sessionStorage.getItem("admin_token"); - return !!token; -}; - -/** - * ✅ Faire une requête authentifiée cabine - */ -export const cabineAuthenticatedFetch = async ( - endpoint: string, - options: RequestInit = {}, -): Promise => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...options.headers, - }; - - return fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); -}; - -// ============================================ -// 📦 GESTION ITEMS (CABINE) -// ============================================ - -/** - * ✅ Récupérer les items d'une commande (Cabine) - * GET /api/v1/cabine/commands/:id/items - */ -export const getCommandItems = async (commandId: number) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("📦 [GET_COMMAND_ITEMS] Appel:", commandId); - - const response = await fetch(`${API_URL}/commands/${commandId}/items`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_COMMAND_ITEMS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération items"); - } - - console.log("✅ [GET_COMMAND_ITEMS] Réponse:", data); - - return { - success: true, - items: data.items || [], - count: data.count || 0, - command_info: data.command_info, - client_info: data.client_info, - }; - } catch (error) { - console.error("❌ [GET_COMMAND_ITEMS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Mettre à jour le statut d'un item (Cabine) - * PUT /api/v1/cabine/items/:item_id/status - */ -export const updateItemStatus = async (itemId: number, status: string) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("📝 [UPDATE_ITEM_STATUS] Appel:", itemId, status); - - const response = await fetch(`${API_URL}/items/${itemId}/status`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ status }), - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_ITEM_STATUS] Erreur API:", data); - throw new Error(data.error || "Erreur mise à jour item"); - } - - console.log("✅ [UPDATE_ITEM_STATUS] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_ITEM_STATUS] Erreur fetch:", error); - throw error; - } -}; - -// ============================================ -// ⚠️ GESTION PÉNALITÉS (CABINE) -// ============================================ - -/** - * ✅ Appliquer une pénalité à un client (Cabine) - * POST /api/v1/cabine/penalty - */ -export const applyClientPenalty = async ( - clientUsername: string, - reason: string, - amount?: number, -) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("⚠️ [APPLY_PENALTY] Appel:", clientUsername, reason); - - const response = await fetch(`${API_URL}/penalty`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - client_username: clientUsername, - reason, - amount, - }), - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [APPLY_PENALTY] Erreur API:", data); - throw new Error(data.error || "Erreur application pénalité"); - } - - console.log("✅ [APPLY_PENALTY] Pénalité appliquée"); - - return { - success: true, - message: data.message, - penalty: data.penalty, - }; - } catch (error) { - console.error("❌ [APPLY_PENALTY] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer les pénalités d'un client (Cabine) - * GET /api/v1/cabine/client/:username/penalties - */ -export const getClientPenalties = async (clientUsername: string) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("🔍 [GET_CLIENT_PENALTIES] Appel:", clientUsername); - - const response = await fetch( - `${API_URL}/client/${clientUsername}/penalties`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_CLIENT_PENALTIES] Erreur API:", data); - throw new Error(data.error || "Erreur récupération pénalités"); - } - - console.log("✅ [GET_CLIENT_PENALTIES] Réponse:", data); - - return { - success: true, - penalties: data.penalties, - }; - } catch (error) { - console.error("❌ [GET_CLIENT_PENALTIES] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Réinitialiser les pénalités d'un client (Cabine) - * POST /api/v1/cabine/client/:username/penalties/reset - */ -export const resetClientPenalties = async (clientUsername: string) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("🔄 [RESET_PENALTIES] Appel:", clientUsername); - - const response = await fetch( - `${API_URL}/client/${clientUsername}/penalties/reset`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [RESET_PENALTIES] Erreur API:", data); - throw new Error(data.error || "Erreur réinitialisation pénalités"); - } - - console.log("✅ [RESET_PENALTIES] Pénalités réinitialisées"); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [RESET_PENALTIES] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer tous les clients avec pénalités (Cabine) - * GET /api/v1/cabine/penalties/all - */ -export const getAllClientsWithPenalties = async () => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("📊 [GET_ALL_PENALTIES] Appel"); - - const response = await fetch(`${API_URL}/penalties/all`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_PENALTIES] Erreur API:", data); - throw new Error(data.error || "Erreur récupération pénalités"); - } - - console.log("✅ [GET_ALL_PENALTIES] Réponse:", data); - - return { - success: true, - clients: data.clients || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_ALL_PENALTIES] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer les statistiques des pénalités (Cabine) - * GET /api/v1/cabine/penalties/stats - */ -export const getPenaltiesStats = async () => { - const token = getCabineAuthToken(); - if (!token) { - throw new Error("Token cabine non trouvé"); - } - try { - console.log("📊 [GET_PENALTIES_STATS] Appel"); - - // ✅ CORRECTION 1: Utiliser des parenthèses () au lieu de backticks `` - const response = await fetch(`${API_URL}/penalties/stats`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_PENALTIES_STATS] Erreur API:", data); - throw new Error(data.error || "Erreur récupération stats"); - } - - console.log("✅ [GET_PENALTIES_STATS] Réponse:", data); - - // ✅ CORRECTION 2: Retourner data.data au lieu de data.stats - return { - success: true, - data: data.data, // ← Changement ici - }; - } catch (error) { - console.error("❌ [GET_PENALTIES_STATS] Erreur fetch:", error); - throw error; - } -}; - -// ============================================ -// 📋 GESTION COMMANDES (CABINE) -// ============================================ - -/** - * ✅ Récupérer les commandes annulées (Cabine) - * GET /api/v1/cabine/commands/cancelled - */ -export const getCancelledOrders = async () => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("🚫 [GET_CANCELLED_ORDERS] Appel"); - - const response = await fetch(`${API_URL}/commands/cancelled`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_CANCELLED_ORDERS] Erreur API:", data); - throw new Error( - data.error || "Erreur récupération commandes annulées", - ); - } - - console.log("✅ [GET_CANCELLED_ORDERS] Réponse:", data); - - return { - success: true, - commands: data.commands || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_CANCELLED_ORDERS] Erreur fetch:", error); - throw error; - } -}; - -/** - * ✅ Récupérer la position du livreur pour une commande (Cabine) - * GET /api/v1/cabine/commands/:id/deliveryman/location - */ -export const getDeliverymanLocationForCommand = async (commandId: number) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("📍 [GET_DELIVERYMAN_LOCATION] Appel:", commandId); - - const response = await fetch( - `${API_URL}/commands/${commandId}/deliveryman/location`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERYMAN_LOCATION] Erreur API:", data); - return { - success: false, - error: data.error, - message: data.message, - command_info: data.command_info, - }; - } - - console.log("✅ [GET_DELIVERYMAN_LOCATION] Réponse:", data); - - return { - success: true, - data: data.data, - }; - } catch (error) { - console.error("❌ [GET_DELIVERYMAN_LOCATION] Erreur fetch:", error); - throw error; - } -}; - -// ============================================ -// 👥 GESTION LIVREURS (CABINE) -// ============================================ - -/** - * ✅ Récupérer tous les livreurs avec détails COMPLETS (Cabine) - * GET /api/v1/cabine/all/deliveryman + enrichissement avec détails - */ -export const getAllDeliveryPersonsWithDetails = - async (): Promise => { - const token = getCabineAuthToken(); - if (!token) throw new Error("Token cabine non trouvé"); - - try { - console.log("👥 [GET_ALL_DELIVERY_PERSONS] Appel"); - - const response = await fetch(`${API_URL}/all/deliveryman`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error( - "❌ [GET_ALL_DELIVERY_PERSONS] Erreur API:", - data, - ); - return { - success: false, - error: data.error, - deliveryPersons: [], - count: 0, - stats: { - total: 0, - available: 0, - busy: 0, - offline: 0, - active_deliveries: 0, - }, - }; - } - - console.log("✅ [GET_ALL_DELIVERY_PERSONS] Réponse brute:", data); - - const users = data.users || []; - - // ✅ Enrichir chaque livreur avec ses vraies données - const deliveryPersonsPromises = users.map( - async (u: any): Promise => { - try { - const detailsResponse = await fetch( - `http://localhost:8080/api/v2/admin/protected/delivery-persons/${u.username}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!detailsResponse.ok) { - console.warn( - `⚠️ [GET_DELIVERY_DETAILS] Erreur pour ${u.username}`, - ); - return { - id: u.id, - username: u.username, - status: "offline", - location: { - latitude: 0, - longitude: 0, - last_update: new Date().toISOString(), - is_recent: false, - }, - stats: { - total_deliveries: 0, - completed_today: 0, - queue_size: 0, - current_command: null, - }, - }; - } - - const details = await detailsResponse.json(); - console.log(`📋 [DETAILS] ${u.username}:`, details); - - const deliveryman = details.deliveryman || details; - - let parsedStatus: "available" | "busy" | "offline" = - "offline"; - if (deliveryman.status) { - if ( - typeof deliveryman.status === "string" && - deliveryman.status.startsWith("{") - ) { - try { - const statusObj = JSON.parse( - deliveryman.status, - ); - parsedStatus = - statusObj.status || "offline"; - } catch (e) { - parsedStatus = deliveryman.status as any; - } - } else { - parsedStatus = deliveryman.status; - } - } - - console.log( - ` ✅ Status parsé pour ${u.username}: "${parsedStatus}"`, - ); - - const hasLocation = - deliveryman.location && - deliveryman.location.latitude && - deliveryman.location.longitude; - - let locationData = { - latitude: 0, - longitude: 0, - last_update: new Date().toISOString(), - is_recent: false, - }; - - if (hasLocation) { - const lastUpdate = deliveryman.location.last_update - ? new Date( - deliveryman.location.last_update * 1000, - ).toISOString() - : new Date().toISOString(); - - locationData = { - latitude: deliveryman.location.latitude, - longitude: deliveryman.location.longitude, - last_update: lastUpdate, - is_recent: - deliveryman.location.is_recent || false, - }; - - console.log( - ` 📍 Location pour ${u.username}:`, - locationData, - ); - } else { - console.log( - ` ⚠️ Pas de location pour ${u.username}`, - ); - } - - return { - id: u.id, - username: u.username, - status: parsedStatus, - location: locationData, - stats: { - total_deliveries: - deliveryman.total_deliveries || 0, - completed_today: - deliveryman.completed_deliveries || 0, - queue_size: deliveryman.queue_size || 0, - current_command: - deliveryman.current_command || null, - }, - }; - } catch (error) { - console.error( - `❌ [GET_DETAILS] Erreur pour ${u.username}:`, - error, - ); - return { - id: u.id, - username: u.username, - status: "offline", - location: { - latitude: 0, - longitude: 0, - last_update: new Date().toISOString(), - is_recent: false, - }, - stats: { - total_deliveries: 0, - completed_today: 0, - queue_size: 0, - current_command: null, - }, - }; - } - }, - ); - - const deliveryPersons = await Promise.all(deliveryPersonsPromises); - - console.log( - "✅ [GET_ALL_DELIVERY_PERSONS] Livreurs enrichis:", - deliveryPersons, - ); - - const stats: DeliveryPersonsStats = { - total: deliveryPersons.length, - available: deliveryPersons.filter( - (d) => d.status === "available", - ).length, - busy: deliveryPersons.filter((d) => d.status === "busy").length, - offline: deliveryPersons.filter((d) => d.status === "offline") - .length, - active_deliveries: deliveryPersons.filter( - (d) => d.stats.current_command !== null, - ).length, - }; - - console.log("📊 [GET_ALL_DELIVERY_PERSONS] Stats:", stats); - - return { - success: true, - deliveryPersons, - stats, - count: deliveryPersons.length, - }; - } catch (error) { - console.error("❌ [GET_ALL_DELIVERY_PERSONS] Erreur fetch:", error); - return { - success: false, - deliveryPersons: [], - stats: { - total: 0, - available: 0, - busy: 0, - offline: 0, - active_deliveries: 0, - }, - count: 0, - error: error instanceof Error ? error.message : "Erreur réseau", - }; - } - }; - -/** - * ✅ Récupérer les liens de navigation GPS pour un livreur (Cabine) - * GET /api/v1/cabine/deliveryman/:username/map-links - */ -export const getDeliveryPersonMapLinks = async ( - username: string, -): Promise => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("🗺️ [GET_DELIVERY_PERSON_MAP_LINKS] Appel:", username); - - const response = await fetch( - `${API_URL}/deliveryman/${username}/location`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error( - "❌ [GET_DELIVERY_PERSON_MAP_LINKS] Erreur API:", - data, - ); - return { - success: false, - error: data.error, - message: data.message, - }; - } - - console.log("✅ [GET_DELIVERY_PERSON_MAP_LINKS] Réponse:", data); - - return { - success: true, - location: data.location, - map_links: data.map_links, - }; - } catch (error) { - console.error( - "❌ [GET_DELIVERY_PERSON_MAP_LINKS] Erreur fetch:", - error, - ); - return { - success: false, - error: "Erreur réseau", - message: "Impossible de récupérer les liens GPS", - }; - } -}; - -export const getDeliverymanCommands = async (username: string) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("📦 [GET_DELIVERYMAN_COMMANDS] Appel pour:", username); - - const response = await fetch( - `http://${API_URL}/api/v2/admin/protected/orders?username=${username}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!response.ok) { - const errorData = await response.json(); - console.error( - "❌ [GET_DELIVERYMAN_COMMANDS] Erreur API:", - errorData, - ); - throw new Error(errorData.error || "Erreur récupération commandes"); - } - - const data = await response.json(); - console.log("✅ [GET_DELIVERYMAN_COMMANDS] Réponse:", data); - - return { - success: true, - commands: data.commands || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_DELIVERYMAN_COMMANDS] Erreur fetch:", error); - return { - success: false, - commands: [], - count: 0, - }; - } -}; - -/** - * ⭐ NOUVEAU - Supprimer une commande (Cabine) - * DELETE /api/v1/cabine/commands/:id - */ -export const deleteCommand = async (commandId: number) => { - const token = getCabineAuthToken(); - - if (!token) { - throw new Error("Token cabine non trouvé"); - } - - try { - console.log("🗑️ [DELETE_COMMAND] Suppression commande:", commandId); - - const response = await fetch(`${API_URL}/commands/${commandId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [DELETE_COMMAND] Erreur API:", data); - throw new Error(data.error || "Erreur suppression commande"); - } - - console.log("✅ [DELETE_COMMAND] Commande supprimée avec succès"); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [DELETE_COMMAND] Erreur fetch:", error); - throw error; - } -}; -export const getActiveAlerts = async (): Promise<{ - success: boolean; - alerts?: Alert[]; - count?: number; - error?: string; -}> => { - const token = getCabineAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_ACTIVE_ALERTS] Appel API"); - - const response = await fetch(`${API_URL}/alerts`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ACTIVE_ALERTS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_ACTIVE_ALERTS] Alertes récupérées:", data); - - return { - success: true, - alerts: data.alerts || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_ACTIVE_ALERTS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -export const getAllAlerts = async (): Promise<{ - success: boolean; - alerts?: Alert[]; - count?: number; // ✅ Ajout de count - error?: string; -}> => { - const token = getCabineAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_ALL_ALERTS] Appel API"); - - const response = await fetch(`${API_URL}/all/alerts`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALL_ALERTS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_ALL_ALERTS] Alertes récupérées:", data); - - return { - success: true, - alerts: data.alerts || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_ALL_ALERTS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Récupérer les détails d'une alerte spécifique - * GET /api/v1/cabine/alerts/:id - */ -export const getAlertDetails = async ( - alertId: number, -): Promise<{ - success: boolean; - alert?: Alert; - error?: string; -}> => { - const token = getCabineAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_ALERT_DETAILS] Appel API:", alertId); - - const response = await fetch(`${API_URL}/alerts/${alertId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALERT_DETAILS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_ALERT_DETAILS] Alerte récupérée:", data); - - return { - success: true, - alert: data.alert, - }; - } catch (error) { - console.error("❌ [GET_ALERT_DETAILS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 🔄 EXPORT PAR DÉFAUT -// ============================================ - -export default { - // Auth - getCabineAuthToken, - isCabineAuthenticated, - cabineAuthenticatedFetch, - - // Items - getCommandItems, - updateItemStatus, - - // Pénalités - applyClientPenalty, - getClientPenalties, - resetClientPenalties, - getAllClientsWithPenalties, - getPenaltiesStats, - - getCancelledOrders, - getDeliverymanLocationForCommand, - getDeliverymanCommands, - deleteCommand, - - // Livreurs - getAllDeliveryPersonsWithDetails, - getDeliveryPersonMapLinks, - getAlertDetails, - getActiveAlerts, -}; diff --git a/frontend-prep/src/api/api_delivery.ts b/frontend-prep/src/api/api_delivery.ts deleted file mode 100644 index 2f233d2f..00000000 --- a/frontend-prep/src/api/api_delivery.ts +++ /dev/null @@ -1,727 +0,0 @@ -// ============================================ -// api/api_livreur.ts - LIVREUR API HELPERS -// ============================================ -// ✅ Fonctions helper pour le dashboard livreur -// ✅ Utilise /api/v1/livreur/* endpoints - -const API_URL = "/api/v1/livreur"; - -// ============================================ -// 🔐 TYPES - LIVREUR -// ============================================ - -export interface DeliveryStatus { - status: "available" | "busy" | "offline"; - current_command?: number; - last_update?: number; -} - -export interface QueueInfo { - queue_size: number; - commands: any[]; -} - -export interface DeliveryItem { - id: number; - status: string; - adresse: string; - total_prix: number; - created_at: string; - updated_at: string; - eta?: string; -} - -export interface ClientInfo { - username: string; - nom?: string; - prenom?: string; - telephone?: string; -} - -export interface DeliveryDetails { - delivery: DeliveryItem; - client_info: ClientInfo; -} - -export interface Alert { - id: number; - username: string; - status: string; - created_at: string; - updated_at: string; -} - -// ============================================ -// 🔐 GESTION JWT -// ============================================ - -/** - * ✅ Récupérer le token d'authentification - */ -const getAuthToken = (): string | null => { - return sessionStorage.getItem("admin_token"); -}; - -export const isDeliveryAuthenticated = (): boolean => { - const token = sessionStorage.getItem("admin_token"); - return !!token; -}; - -// ============================================ -// 📊 STATUT DU LIVREUR -// ============================================ - -/** - * ✅ Récupérer le statut actuel du livreur - * GET /api/v1/livreur/status - */ -export const getMyStatus = async (): Promise<{ - success: boolean; - status?: DeliveryStatus; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_MY_STATUS] Appel API"); - - const response = await fetch(`${API_URL}/status`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MY_STATUS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_MY_STATUS] Réponse:", data); - - return { - success: true, - status: data.status, - }; - } catch (error) { - console.error("❌ [GET_MY_STATUS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Mettre à jour le statut du livreur - * POST /api/v1/livreur/status - */ -export const updateMyStatus = async ( - status: "available" | "busy" | "offline", -): Promise<{ success: boolean; message?: string; error?: string }> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("📝 [UPDATE_MY_STATUS] Appel API:", status); - - const response = await fetch(`${API_URL}/update/status`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ status }), - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_MY_STATUS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [UPDATE_MY_STATUS] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_MY_STATUS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 📦 QUEUE DU LIVREUR -// ============================================ - -/** - * ✅ Récupérer la queue de livraisons du livreur - * GET /api/v1/livreur/queue - */ -export const getMyQueue = async (): Promise<{ - success: boolean; - queue_info?: QueueInfo; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_MY_QUEUE] Appel API"); - - const response = await fetch(`${API_URL}/queue`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MY_QUEUE] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_MY_QUEUE] Réponse:", data); - - return { - success: true, - queue_info: data.queue_info, - }; - } catch (error) { - console.error("❌ [GET_MY_QUEUE] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 🚚 LIVRAISONS -// ============================================ - -/** - * ✅ Récupérer toutes les livraisons du livreur - * GET /api/v1/livreur/deliveries - */ -export const getMyDeliveries = async (): Promise<{ - success: boolean; - deliveries?: DeliveryItem[]; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_MY_DELIVERIES] Appel API"); - - const response = await fetch(`${API_URL}/deliveries`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MY_DELIVERIES] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_MY_DELIVERIES] Réponse:", data); - - return { - success: true, - deliveries: data.deliveries || [], - }; - } catch (error) { - console.error("❌ [GET_MY_DELIVERIES] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Récupérer les détails d'une livraison spécifique - * GET /api/v1/livreur/deliveries/:id - */ -export const getDeliveryDetails = async ( - deliveryId: number, -): Promise<{ - success: boolean; - delivery?: DeliveryDetails; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_DELIVERY_DETAILS] Appel API:", deliveryId); - - const response = await fetch(`${API_URL}/deliveries/${deliveryId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_DELIVERY_DETAILS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_DELIVERY_DETAILS] Réponse:", data); - - return { - success: true, - delivery: { - delivery: data.delivery, - client_info: data.client_info, - }, - }; - } catch (error) { - console.error("❌ [GET_DELIVERY_DETAILS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Démarrer une livraison - * POST /api/v1/livreur/deliveries/:id/start - */ -export const startDelivery = async ( - deliveryId: number, - latitude: number, - longitude: number, -): Promise<{ success: boolean; message?: string; error?: string }> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🚀 [START_DELIVERY] Appel API:", deliveryId); - - const response = await fetch( - `${API_URL}/deliveries/${deliveryId}/start`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ latitude, longitude }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [START_DELIVERY] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [START_DELIVERY] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [START_DELIVERY] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Mettre à jour le statut d'une livraison - * PUT /api/v1/livreur/deliveries/:id/status - */ -export const updateDeliveryStatus = async ( - deliveryId: number, - status: string, - latitude: number, - longitude: number, -): Promise<{ success: boolean; message?: string; error?: string }> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log( - "📝 [UPDATE_DELIVERY_STATUS] Appel API:", - deliveryId, - status, - ); - - const response = await fetch( - `${API_URL}/deliveries/${deliveryId}/status`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ status, latitude, longitude }), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [UPDATE_DELIVERY_STATUS] Réponse:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 📍 POSITION GPS -// ============================================ - -/** - * ✅ Mettre à jour la position GPS du livreur - * POST /api/v1/livreur/location/update - */ -export const updateMyLocation = async ( - latitude: number, - longitude: number, -): Promise<{ - success: boolean; - message?: string; - status?: string; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - const response = await fetch(`${API_URL}/location/update`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ latitude, longitude }), - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [UPDATE_MY_LOCATION] Erreur API:", data); - return { success: false, error: data.error }; - } - - return { - success: true, - message: data.message, - status: data.status, - }; - } catch (error) { - console.error("❌ [UPDATE_MY_LOCATION] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Récupérer la position actuelle du livreur - * GET /api/v1/livreur/location - */ -export const getMyLocation = async (): Promise<{ - success: boolean; - latitude?: number; - longitude?: number; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - const response = await fetch(`${API_URL}/location`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MY_LOCATION] Erreur API:", data); - return { success: false, error: data.error }; - } - - return { - success: true, - latitude: data.latitude, - longitude: data.longitude, - }; - } catch (error) { - console.error("❌ [GET_MY_LOCATION] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 🚨 ALERTES POLICE -// ============================================ - -/** - * ✅ Déclencher une alerte police - * POST /api/v1/livreur/alert - */ -export const triggerPoliceAlert = async (): Promise<{ - success: boolean; - alert_id?: number; - message?: string; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🚨 [TRIGGER_POLICE_ALERT] Déclenchement alerte police"); - - const response = await fetch(`${API_URL}/alert`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [TRIGGER_POLICE_ALERT] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [TRIGGER_POLICE_ALERT] Alerte créée:", data); - - return { - success: true, - alert_id: data.alert_id, - message: data.message, - }; - } catch (error) { - console.error("❌ [TRIGGER_POLICE_ALERT] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Mettre fin à une alerte - * DELETE /api/v1/livreur/alert/:id - */ -export const endAlert = async ( - alertId: number, -): Promise<{ - success: boolean; - message?: string; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔚 [END_ALERT] Terminer alerte:", alertId); - - const response = await fetch(`${API_URL}/alert/${alertId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [END_ALERT] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [END_ALERT] Alerte terminée:", data); - - return { - success: true, - message: data.message, - }; - } catch (error) { - console.error("❌ [END_ALERT] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Récupérer toutes mes alertes - * GET /api/v1/livreur/alerts - */ -export const getMyAlerts = async (): Promise<{ - success: boolean; - alerts?: Alert[]; - count?: number; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_MY_ALERTS] Récupération alertes"); - - const response = await fetch(`${API_URL}/alerts`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_MY_ALERTS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_MY_ALERTS] Alertes récupérées:", data); - - return { - success: true, - alerts: data.alerts || [], - count: data.count || 0, - }; - } catch (error) { - console.error("❌ [GET_MY_ALERTS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -/** - * ✅ Récupérer les détails d'une alerte - * GET /api/v1/livreur/alert/:id - */ -export const getAlertDetails = async ( - alertId: number, -): Promise<{ - success: boolean; - alert?: Alert; - error?: string; -}> => { - const token = getAuthToken(); - - if (!token) { - return { success: false, error: "Token non trouvé" }; - } - - try { - console.log("🔍 [GET_ALERT_DETAILS] Récupération alerte:", alertId); - - const response = await fetch(`${API_URL}/alert/${alertId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - console.error("❌ [GET_ALERT_DETAILS] Erreur API:", data); - return { success: false, error: data.error }; - } - - console.log("✅ [GET_ALERT_DETAILS] Alerte récupérée:", data); - - return { - success: true, - alert: data.alert, - }; - } catch (error) { - console.error("❌ [GET_ALERT_DETAILS] Erreur fetch:", error); - return { success: false, error: "Erreur réseau" }; - } -}; - -// ============================================ -// 🔄 EXPORT PAR DÉFAUT -// ============================================ - -export default { - // Statut - getMyStatus, - updateMyStatus, - - // Queue - getMyQueue, - - // Livraisons - getMyDeliveries, - getDeliveryDetails, - startDelivery, - updateDeliveryStatus, - - // Position GPS - updateMyLocation, - getMyLocation, - - // Alertes Police - triggerPoliceAlert, - endAlert, - getMyAlerts, - getAlertDetails, -}; diff --git a/frontend-prep/src/components/AdminLayout.css b/frontend-prep/src/components/AdminLayout.css deleted file mode 100644 index f8fe2389..00000000 --- a/frontend-prep/src/components/AdminLayout.css +++ /dev/null @@ -1,12 +0,0 @@ -.admin-layout { - display: flex; - min-height: 100vh; - background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%); -} - -.admin-content { - flex: 1; - margin-left: 0; - transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); - overflow-x: hidden; -} \ No newline at end of file diff --git a/frontend-prep/src/components/AdminLayout.tsx b/frontend-prep/src/components/AdminLayout.tsx deleted file mode 100644 index 77d836d4..00000000 --- a/frontend-prep/src/components/AdminLayout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Sidebar from './Sidebar'; -import './AdminLayout.css'; - -interface AdminLayoutProps { - children: React.ReactNode; -} - -function AdminLayout({ children }: AdminLayoutProps) { - return ( -
- -
- {children} -
-
- ); -} - -export default AdminLayout; \ No newline at end of file diff --git a/frontend-prep/src/components/ConfirmModal.css b/frontend-prep/src/components/ConfirmModal.css deleted file mode 100644 index 210f1fb8..00000000 --- a/frontend-prep/src/components/ConfirmModal.css +++ /dev/null @@ -1,388 +0,0 @@ -/* ============================================ - CONFIRM MODAL - STYLES PROFESSIONNELS - ============================================ */ - -.confirm-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - padding: 1rem; - animation: overlayFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes overlayFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -/* ============================================ - CONTAINER - ============================================ */ -.confirm-modal-container { - background: linear-gradient( - 135deg, - rgba(30, 30, 40, 0.98) 0%, - rgba(20, 20, 30, 0.98) 100% - ); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 20px; - width: 100%; - max-width: 480px; - box-shadow: - 0 20px 60px rgba(0, 0, 0, 0.5), - inset 0 1px 1px rgba(255, 255, 255, 0.1); - animation: modalSlideIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); - overflow: hidden; -} - -@keyframes modalSlideIn { - from { - transform: scale(0.9) translateY(20px); - opacity: 0; - } - to { - transform: scale(1) translateY(0); - opacity: 1; - } -} - -/* ============================================ - HEADER - ============================================ */ -.confirm-modal-header { - position: relative; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem 2rem 1.5rem 2rem; -} - -.confirm-modal-icon { - width: 72px; - height: 72px; - border-radius: 16px; - display: flex; - align-items: center; - justify-content: center; - position: relative; - animation: iconPulse 2s ease-in-out infinite; -} - -@keyframes iconPulse { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.05); - } -} - -.confirm-modal-icon-danger { - background: linear-gradient( - 135deg, - rgba(239, 68, 68, 0.2), - rgba(220, 38, 38, 0.1) - ); - border: 2px solid rgba(239, 68, 68, 0.3); - color: #ef4444; - box-shadow: 0 0 30px rgba(239, 68, 68, 0.3); -} - -.confirm-modal-icon-warning { - background: linear-gradient( - 135deg, - rgba(245, 158, 11, 0.2), - rgba(217, 119, 6, 0.1) - ); - border: 2px solid rgba(245, 158, 11, 0.3); - color: #f59e0b; - box-shadow: 0 0 30px rgba(245, 158, 11, 0.3); -} - -.confirm-modal-icon-info { - background: linear-gradient( - 135deg, - rgba(59, 130, 246, 0.2), - rgba(37, 99, 235, 0.1) - ); - border: 2px solid rgba(59, 130, 246, 0.3); - color: #3b82f6; - box-shadow: 0 0 30px rgba(59, 130, 246, 0.3); -} - -.confirm-modal-icon-success { - background: linear-gradient( - 135deg, - rgba(16, 185, 129, 0.2), - rgba(5, 150, 105, 0.1) - ); - border: 2px solid rgba(16, 185, 129, 0.3); - color: #10b981; - box-shadow: 0 0 30px rgba(16, 185, 129, 0.3); -} - -.confirm-modal-close { - position: absolute; - top: 1.5rem; - right: 1.5rem; - width: 36px; - height: 36px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: #888; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.confirm-modal-close:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - color: white; - transform: rotate(90deg); -} - -.confirm-modal-close:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* ============================================ - CONTENT - ============================================ */ -.confirm-modal-content { - padding: 0 2rem 2rem 2rem; - text-align: center; -} - -.confirm-modal-title { - color: white; - font-size: 1.5rem; - font-weight: 700; - margin: 0 0 1rem 0; - letter-spacing: -0.5px; - line-height: 1.3; -} - -.confirm-modal-message { - color: rgba(255, 255, 255, 0.7); - font-size: 1rem; - line-height: 1.6; - margin: 0; - max-width: 380px; - margin: 0 auto; -} - -/* ============================================ - FOOTER - ============================================ */ -.confirm-modal-footer { - display: flex; - gap: 1rem; - padding: 0 2rem 2rem 2rem; -} - -.confirm-modal-btn { - flex: 1; - padding: 1rem 1.5rem; - border-radius: 12px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border: 1px solid transparent; -} - -.confirm-modal-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* Cancel Button */ -.confirm-modal-btn-cancel { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.85); -} - -.confirm-modal-btn-cancel:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - color: white; - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); -} - -/* Confirm Buttons - Variants */ -.confirm-modal-btn-confirm { - font-weight: 700; -} - -.confirm-modal-btn-danger { - background: linear-gradient(135deg, #ef4444, #dc2626); - border-color: rgba(239, 68, 68, 0.3); - color: white; - box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); -} - -.confirm-modal-btn-danger:hover:not(:disabled) { - background: linear-gradient(135deg, #f87171, #ef4444); - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(239, 68, 68, 0.5); -} - -.confirm-modal-btn-warning { - background: linear-gradient(135deg, #f59e0b, #d97706); - border-color: rgba(245, 158, 11, 0.3); - color: white; - box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3); -} - -.confirm-modal-btn-warning:hover:not(:disabled) { - background: linear-gradient(135deg, #fbbf24, #f59e0b); - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(245, 158, 11, 0.5); -} - -.confirm-modal-btn-info { - background: linear-gradient(135deg, #3b82f6, #2563eb); - border-color: rgba(59, 130, 246, 0.3); - color: white; - box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3); -} - -.confirm-modal-btn-info:hover:not(:disabled) { - background: linear-gradient(135deg, #60a5fa, #3b82f6); - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(59, 130, 246, 0.5); -} - -.confirm-modal-btn-success { - background: linear-gradient(135deg, #10b981, #059669); - border-color: rgba(16, 185, 129, 0.3); - color: white; - box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3); -} - -.confirm-modal-btn-success:hover:not(:disabled) { - background: linear-gradient(135deg, #34d399, #10b981); - transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(16, 185, 129, 0.5); -} - -/* ============================================ - LOADING STATE - ============================================ */ -.confirm-modal-loading { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.spinner { - width: 16px; - height: 16px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: white; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ============================================ - RESPONSIVE - ============================================ */ -@media (max-width: 640px) { - .confirm-modal-container { - max-width: 100%; - margin: 1rem; - border-radius: 16px; - } - - .confirm-modal-header { - padding: 1.5rem 1.5rem 1rem 1.5rem; - } - - .confirm-modal-content { - padding: 0 1.5rem 1.5rem 1.5rem; - } - - .confirm-modal-footer { - flex-direction: column; - padding: 0 1.5rem 1.5rem 1.5rem; - } - - .confirm-modal-btn { - width: 100%; - } - - .confirm-modal-icon { - width: 64px; - height: 64px; - } - - .confirm-modal-title { - font-size: 1.3rem; - } - - .confirm-modal-message { - font-size: 0.95rem; - } - - .confirm-modal-close { - top: 1rem; - right: 1rem; - width: 32px; - height: 32px; - } -} - -@media (max-width: 480px) { - .confirm-modal-icon { - width: 56px; - height: 56px; - } - - .confirm-modal-title { - font-size: 1.2rem; - } -} - -/* Mobile Touch Optimization */ -@media (hover: none) { - .confirm-modal-btn:hover { - transform: none; - } - - .confirm-modal-close:hover { - transform: none; - } -} diff --git a/frontend-prep/src/components/ConfirmModal.tsx b/frontend-prep/src/components/ConfirmModal.tsx deleted file mode 100644 index a2742979..00000000 --- a/frontend-prep/src/components/ConfirmModal.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { X, AlertTriangle, Trash2, CheckCircle, Info } from "lucide-react"; -import "./ConfirmModal.css"; - -interface ConfirmModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title: string; - message: string; - type?: "danger" | "warning" | "info" | "success"; - confirmText?: string; - cancelText?: string; - isLoading?: boolean; -} - -export function ConfirmModal({ - isOpen, - onClose, - onConfirm, - title, - message, - type = "warning", - confirmText = "Confirmer", - cancelText = "Annuler", - isLoading = false, -}: ConfirmModalProps) { - if (!isOpen) return null; - - const getIcon = () => { - switch (type) { - case "danger": - return ; - case "warning": - return ; - case "success": - return ; - case "info": - return ; - default: - return ; - } - }; - - const handleConfirm = () => { - if (!isLoading) { - onConfirm(); - } - }; - - const handleCancel = () => { - if (!isLoading) { - onClose(); - } - }; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
- {getIcon()} -
- -
- - {/* Content */} -
-

{title}

-

{message}

-
- - {/* Footer */} -
- - -
-
-
- ); -} - -export default ConfirmModal; diff --git a/frontend-prep/src/components/EditUserModal.css b/frontend-prep/src/components/EditUserModal.css deleted file mode 100644 index 1b20dd2a..00000000 --- a/frontend-prep/src/components/EditUserModal.css +++ /dev/null @@ -1,429 +0,0 @@ -/* ============================================ - EditUserModal.css - ============================================ */ - -/* ✅ Modal overlay - PAS DE Z-INDEX */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.8); - /* ✅ PAS DE Z-INDEX pour ne pas cacher la modal */ - backdrop-filter: blur(8px); - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.edit-user-modal { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90%; - max-width: 700px; - max-height: 90vh; - background: linear-gradient(135deg, #1a1a1a, #0f0f0f); - border: 1px solid rgba(124, 58, 237, 0.3); - border-radius: 20px; - z-index: 9999; /* ✅ Seule la modal a un z-index */ - overflow: hidden; - display: flex; - flex-direction: column; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translate(-50%, -45%); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } -} - -/* Alerts */ -.alert { - padding: 1rem 1.2rem; - border-radius: 10px; - margin-bottom: 1.5rem; - display: flex; - align-items: center; - gap: 0.8rem; - font-size: 0.95rem; - font-weight: 600; - animation: slideInDown 0.3s ease; -} - -@keyframes slideInDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.alert-error { - background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1)); - border: 1px solid rgba(239, 68, 68, 0.3); - color: #ef4444; -} - -.alert-success { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1)); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #10b981; -} - -/* Form sections */ -.form-section { - margin-bottom: 2rem; -} - -.form-section:last-child { - margin-bottom: 0; -} - -.form-section h3 { - color: white; - font-size: 1.1rem; - margin: 0 0 1rem 0; - font-weight: bold; - border-bottom: 2px solid rgba(124, 58, 237, 0.3); - padding-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.form-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1.2rem; -} - -.form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.form-group.full-width { - grid-column: 1 / -1; -} - -.form-group label { - color: #888; - font-size: 0.85rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.form-group label svg { - color: #7c3aed; -} - -.form-group input, -.form-group select { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - padding: 0.9rem 1rem; - color: white; - font-size: 0.95rem; - font-weight: 500; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - outline: none; -} - -.form-group input:focus, -.form-group select:focus { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); - border-color: rgba(124, 58, 237, 0.4); - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); -} - -.form-group input::placeholder { - color: #666; -} - -.form-group input[type="number"] { - appearance: textfield; -} - -.form-group input[type="number"]::-webkit-inner-spin-button, -.form-group input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.role-select { - cursor: pointer; - background: #1a1a1a !important; - width: 100%; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; -} - -.custom-select-wrapper { - position: relative; - width: 100%; -} - -.custom-select-wrapper .select-icon { - position: absolute; - left: 1rem; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - color: #7c3aed; - display: flex; - align-items: center; - z-index: 1; -} - -.custom-select-wrapper .select-arrow { - position: absolute; - right: 1rem; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - color: #888; - font-size: 0.7rem; -} - -.role-select { - padding-left: 3rem !important; -} - -.role-select option { - background-color: #1a1a1a !important; - color: white !important; - padding: 0.8rem !important; -} - -.role-select option:hover, -.role-select option:focus, -.role-select option:active { - background-color: #0f0f0f !important; - background: #0f0f0f !important; - color: white !important; - outline: none !important; - box-shadow: none !important; -} - -.role-select option:checked { - background-color: #7c3aed !important; - background: #7c3aed !important; - color: white !important; - font-weight: 600; -} - -@-moz-document url-prefix() { - .role-select option:hover { - background-color: #0f0f0f !important; - } -} - -.role-select::-ms-expand { - display: none; -} - -.role-select option::selection { - background: #0f0f0f !important; -} - -.role-select option::-moz-selection { - background: #0f0f0f !important; -} - -/* Spinner */ -.spinner { - width: 18px; - height: 18px; - border: 3px solid rgba(255, 255, 255, 0.3); - border-top-color: white; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* Modal header et actions - Styles manquants */ -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.5rem 2rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.modal-header h2 { - color: white; - font-size: 1.5rem; - margin: 0; - font-weight: bold; -} - -.close-modal { - background: transparent; - border: none; - color: #888; - cursor: pointer; - transition: all 0.3s ease; - padding: 0.5rem; - border-radius: 8px; -} - -.close-modal:hover { - color: white; - background: rgba(255, 255, 255, 0.05); - transform: rotate(90deg); -} - -.modal-content { - flex: 1; - overflow-y: auto; - padding: 2rem; -} - -.modal-content::-webkit-scrollbar { - width: 8px; -} - -.modal-content::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.02); -} - -.modal-content::-webkit-scrollbar-thumb { - background: rgba(124, 58, 237, 0.3); - border-radius: 4px; -} - -.modal-actions { - display: flex; - gap: 1rem; - padding: 1.5rem 2rem; - border-top: 1px solid rgba(255, 255, 255, 0.08); - flex-wrap: wrap; -} - -.action-button { - flex: 1; - min-width: 150px; - display: flex; - align-items: center; - justify-content: center; - gap: 0.6rem; - padding: 0.9rem 1.5rem; - border-radius: 12px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - border: none; -} - -.action-button.secondary { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); - border: 1px solid rgba(255, 255, 255, 0.1); - color: white; -} - -.action-button.secondary:hover { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)); - border-color: rgba(255, 255, 255, 0.2); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); -} - -.action-button.primary { - background: linear-gradient(135deg, #7c3aed, #6d28d9); - border: 1px solid rgba(124, 58, 237, 0.3); - color: white; -} - -.action-button.primary:hover { - background: linear-gradient(135deg, #8b5cf6, #7c3aed); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4); -} - -.action-button:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none !important; -} - -/* Responsive */ -@media (max-width: 768px) { - .edit-user-modal { - width: 95%; - max-height: 95vh; - } - - .form-grid { - grid-template-columns: 1fr; - } - - .form-group { - grid-column: 1 / -1 !important; - } - - .modal-header, - .modal-actions { - padding: 1rem 1.5rem; - } - - .modal-content { - padding: 1.5rem; - } - - .modal-actions { - flex-direction: column; - } - - .action-button { - width: 100%; - } -} - -@media (max-width: 480px) { - .form-section h3 { - font-size: 1rem; - } - - .form-group label { - font-size: 0.8rem; - } - - .form-group input, - .form-group select { - padding: 0.8rem; - font-size: 0.9rem; - } -} \ No newline at end of file diff --git a/frontend-prep/src/components/EditUserModal.tsx b/frontend-prep/src/components/EditUserModal.tsx deleted file mode 100644 index cd3f1c6c..00000000 --- a/frontend-prep/src/components/EditUserModal.tsx +++ /dev/null @@ -1,407 +0,0 @@ -// ============================================ -// EditUserModal.tsx -// ============================================ -// Modal de modification des profils utilisateurs (Admin) - -import { useState } from 'react'; -import { XCircle, Save, User, Lock, Shield, Phone, Crown, Building2, Truck } from 'lucide-react'; -import { updateClientByAdmin, updateUserByAdmin } from '../api/api_admin'; -import './EditUserModal.css'; - -interface EditUserModalProps { - user: { - id: number; - username: string; - role: 'client' | 'livreur' | 'admin' | 'cabine'; - nom?: string; - prenom?: string; - telephone?: string; - adresse?: string; - command?: number; - point?: number; - points_zipette?: number; // ✅ AJOUTÉ - amende?: number; - }; - onClose: () => void; - onSuccess: () => void; -} - -function EditUserModal({ user, onClose, onSuccess }: EditUserModalProps) { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - // État du formulaire pour CLIENT - const [clientForm, setClientForm] = useState({ - username: user.username || '', - password: '', - nom: user.nom || '', - prenom: user.prenom || '', - telephone: user.telephone || '', - point: user.point || 0, - points_zipette: user.points_zipette || 0, // ✅ AJOUTÉ - amende: user.amende || 0, - command: user.command || 0, - }); - - // État du formulaire pour USER (admin/cabine/livreur) - const [userForm, setUserForm] = useState({ - username: user.username || '', - password: '', - role: user.role as 'admin' | 'cabine' | 'livreur', - }); - - const isClient = user.role === 'client'; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - setSuccess(null); - - try { - if (isClient) { - // ✅ Modification CLIENT - const updates: any = {}; - - // N'envoyer que les champs modifiés - if (clientForm.username && clientForm.username !== user.username) { - updates.username = clientForm.username; - } - if (clientForm.password) { - updates.password = clientForm.password; - } - if (clientForm.nom && clientForm.nom !== user.nom) { - updates.nom = clientForm.nom; - } - if (clientForm.prenom && clientForm.prenom !== user.prenom) { - updates.prenom = clientForm.prenom; - } - if (clientForm.telephone && clientForm.telephone !== user.telephone) { - updates.telephone = clientForm.telephone; - } - if (clientForm.point !== user.point) { - updates.point = clientForm.point; - } - if (clientForm.points_zipette !== user.points_zipette) { // ✅ AJOUTÉ - updates.points_zipette = clientForm.points_zipette; - } - if (clientForm.amende !== user.amende) { - updates.amende = clientForm.amende; - } - if (clientForm.command !== user.command) { - updates.command = clientForm.command; - } - - if (Object.keys(updates).length === 0) { - setError('Aucune modification détectée'); - setLoading(false); - return; - } - - console.log('📝 [EDIT_CLIENT] Mise à jour:', updates); - - const result = await updateClientByAdmin(user.id, updates); - - if (result.success) { - setSuccess(result.message || 'Client mis à jour avec succès'); - setTimeout(() => { - onSuccess(); - onClose(); - }, 1500); - } - } else { - // ✅ Modification USER (admin/cabine/livreur) - const updates: any = {}; - - if (userForm.username && userForm.username !== user.username) { - updates.username = userForm.username; - } - if (userForm.password) { - updates.password = userForm.password; - } - if (userForm.role && userForm.role !== user.role) { - updates.role = userForm.role; - } - - if (Object.keys(updates).length === 0) { - setError('Aucune modification détectée'); - setLoading(false); - return; - } - - console.log('📝 [EDIT_USER] Mise à jour:', updates); - - const result = await updateUserByAdmin(user.id, updates); - - if (result.success) { - setSuccess(result.message || 'Utilisateur mis à jour avec succès'); - setTimeout(() => { - onSuccess(); - onClose(); - }, 1500); - } - } - } catch (err) { - console.error('❌ [EDIT_MODAL] Erreur:', err); - setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour'); - } finally { - setLoading(false); - } - }; - - return ( - <> -
-
-
-

- {isClient ? 'Modifier le client' : 'Modifier l\'utilisateur'} -

- -
- -
- {/* Messages de succès/erreur */} - {error && ( -
- ❌ {error} -
- )} - {success && ( -
- ✅ {success} -
- )} - - {isClient ? ( - // ============================================ - // FORMULAIRE CLIENT - // ============================================ - <> -
-

Informations personnelles

-
-
- - setClientForm({ ...clientForm, username: e.target.value })} - placeholder="Nom d'utilisateur" - /> -
- -
- - setClientForm({ ...clientForm, password: e.target.value })} - placeholder="Laisser vide pour ne pas changer" - minLength={8} - /> -
- -
- - setClientForm({ ...clientForm, nom: e.target.value })} - placeholder="Nom" - /> -
- -
- - setClientForm({ ...clientForm, prenom: e.target.value })} - placeholder="Prénom" - /> -
- -
- - setClientForm({ ...clientForm, telephone: e.target.value })} - placeholder="+33612345678" - /> -
-
-
- -
-

Statistiques (Admin uniquement)

-
-
- - setClientForm({ ...clientForm, command: parseInt(e.target.value) || 0 })} - min="0" - /> -
- -
- - setClientForm({ ...clientForm, point: parseInt(e.target.value) || 0 })} - min="0" - /> -
- -
- - setClientForm({ ...clientForm, points_zipette: parseInt(e.target.value) || 0 })} - min="0" - /> -
- -
- - setClientForm({ ...clientForm, amende: parseFloat(e.target.value) || 0 })} - min="0" - /> -
-
-
- - ) : ( - // ============================================ - // FORMULAIRE USER (admin/cabine/livreur) - // ============================================ -
-

Informations utilisateur

-
-
- - setUserForm({ ...userForm, username: e.target.value })} - placeholder="Nom d'utilisateur" - /> -
- -
- - setUserForm({ ...userForm, password: e.target.value })} - placeholder="Laisser vide pour ne pas changer" - minLength={8} - /> -
- -
- -
- -
- {userForm.role === 'admin' && } - {userForm.role === 'cabine' && } - {userForm.role === 'livreur' && } -
-
-
-
-
-
- )} - -
- - -
-
-
- - ); -} - -export default EditUserModal; \ No newline at end of file diff --git a/frontend-prep/src/components/Navbar.css b/frontend-prep/src/components/Navbar.css index b925a4e2..eb449904 100644 --- a/frontend-prep/src/components/Navbar.css +++ b/frontend-prep/src/components/Navbar.css @@ -1,419 +1,485 @@ -.navbar { +/* ============================================================ + Tokens + ============================================================ */ +:root { + --topbar-h: 60px; + --sidebar-w: 260px; + --bg: #09090b; + --surface: #111115; + --surface-2: #1c1c22; + --border: rgba(255, 255, 255, 0.07); + --primary: #8b5cf6; + --primary-soft: rgba(139, 92, 246, 0.12); + --primary-glow: rgba(139, 92, 246, 0.25); + --cyan: #22d3ee; + --cyan-soft: rgba(34, 211, 238, 0.1); + --red: #ef4444; + --text: #f4f4f5; + --text-muted: #71717a; + --radius: 10px; + --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ============================================================ + Body offset + ============================================================ */ +body { + padding-top: var(--topbar-h); +} + +/* ============================================================ + Top Bar + ============================================================ */ +.topbar { position: fixed; top: 0; left: 0; right: 0; - height: 60px; - background-color: #1a1a1a; - border-bottom: 2px solid #333; + height: var(--topbar-h); + z-index: 900; display: flex; align-items: center; - padding: 0 1rem; - z-index: 100; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + padding: 0 0.75rem; + gap: 0.5rem; + background: rgba(9, 9, 11, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 24px rgba(0, 0, 0, 0.4); } -.hamburger-menu { +.topbar-toggle { + width: 40px; + height: 40px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; background: transparent; - border: none; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + transition: all var(--transition); + -webkit-tap-highlight-color: transparent; +} + +.topbar-toggle:hover, +.topbar-toggle.is-open { + background: var(--surface-2); + border-color: var(--border); + color: var(--text); +} + +.topbar-brand { + flex: 1; + text-align: center; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.02em; + background: linear-gradient(135deg, #a78bfa, #7c3aed); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + user-select: none; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* ── Icon button (notif / cart) ── */ +.topbar-icon-btn { + position: relative; width: 40px; height: 40px; display: flex; - flex-direction: column; - justify-content: space-around; - padding: 8px; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - transition: all 0.3s; -} - -.hamburger-menu:active { - transform: scale(0.9); -} - -.hamburger-line { - width: 100%; - height: 3px; - background-color: white; - border-radius: 2px; - transition: all 0.3s ease; -} - -.hamburger-line.open:nth-child(1) { - transform: rotate(45deg) translate(6px, 6px); -} - -.hamburger-line.open:nth-child(2) { - opacity: 0; -} - -.hamburger-line.open:nth-child(3) { - transform: rotate(-45deg) translate(6px, -6px); -} - -.navbar-title { - color: white; - font-size: clamp(1.2rem, 4vw, 1.5rem); - margin: 0 0 0 1rem; - font-weight: 600; - flex: 1; -} - -.cart-button { - position: relative; - background: transparent; - border: none; - width: 45px; - height: 45px; align-items: center; justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text-muted); + font-size: 1rem; cursor: pointer; + transition: all var(--transition); -webkit-tap-highlight-color: transparent; - transition: all 0.2s; - margin-left: 330px; } -.cart-button:active { - transform: scale(0.9); +.topbar-icon-btn:hover { + background: var(--surface-2); + border-color: var(--border); + color: var(--text); } -.cart-icon { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); +.topbar-icon-btn:active { + transform: scale(0.93); } -.cart-count { +.topbar-badge { position: absolute; - top: 2px; - right: 2px; - background-color: #ff0000; - color: white; - font-size: 0.7rem; - font-weight: bold; - min-width: 18px; - height: 18px; - border-radius: 50%; + top: 4px; + right: 4px; + min-width: 17px; + height: 17px; + border-radius: 999px; + background: var(--red); + color: #fff; + font-size: 0.62rem; + font-weight: 700; display: flex; align-items: center; justify-content: center; - padding: 2px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + padding: 0 3px; + border: 2px solid var(--bg); + line-height: 1; } -/* Menu latéral */ -.side-menu { +/* ============================================================ + Overlay + ============================================================ */ +.sidebar-overlay { + position: fixed; + inset: 0; + z-index: 950; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ============================================================ + Sidebar + ============================================================ */ +.sidebar { position: fixed; top: 0; - left: -280px; - width: 280px; - height: 100vh; - background-color: #0a0a0a; - border-right: 2px solid #333; - z-index: 200; - transition: left 0.3s ease; - overflow-y: auto; + left: 0; + width: var(--sidebar-w); + height: 100dvh; + z-index: 1000; display: flex; flex-direction: column; + background: var(--bg); + border-right: 1px solid var(--border); + transform: translateX(-100%); + transition: transform var(--transition); + overflow: hidden; } -.side-menu.open { - left: 0; +.sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 40px rgba(0, 0, 0, 0.6); } -.side-menu-header { +/* ── Header ── */ +.sidebar-header { display: flex; - justify-content: space-between; align-items: center; - padding: 1.5rem 1rem; - border-bottom: 2px solid #333; + justify-content: space-between; + padding: 1.25rem 1rem 1rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; } -.side-menu-header h3 { - color: white; - margin: 0; - font-size: 1.5rem; +.sidebar-brand { + display: flex; + align-items: center; + gap: 0.75rem; } -.close-menu { - background: transparent; - border: none; - color: white; - font-size: 2.5rem; - cursor: pointer; - width: 40px; - height: 40px; +.sidebar-brand-icon { + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--primary-soft); + border: 1px solid rgba(139, 92, 246, 0.25); display: flex; align-items: center; justify-content: center; + color: var(--primary); + font-size: 1rem; + flex-shrink: 0; +} + +.sidebar-brand-name { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--text); + letter-spacing: 0.01em; +} + +.sidebar-brand-sub { + margin: 0; + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 1px; +} + +.sidebar-close { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + font-size: 0.85rem; + cursor: pointer; + transition: all var(--transition); -webkit-tap-highlight-color: transparent; - transition: all 0.2s; + flex-shrink: 0; } -.close-menu:active { - transform: scale(0.9); +.sidebar-close:hover { + background: var(--surface-2); + color: var(--text); } -.side-menu-nav { +/* ── Nav ── */ +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.75rem; + scrollbar-width: none; +} + +.sidebar-nav::-webkit-scrollbar { + display: none; +} + +.menu-list { + list-style: none; + margin: 0; + padding: 0; display: flex; flex-direction: column; - padding: 1rem 0; - flex: 1; + gap: 2px; } .menu-item { - color: white; - text-decoration: none; - padding: 1rem 1.5rem; - font-size: 1.1rem; - border-bottom: 1px solid #222; - transition: all 0.2s; - -webkit-tap-highlight-color: transparent; -} - -.menu-item:active { - background-color: #1a1a1a; - transform: translateX(5px); -} - -/* Overlay */ -.menu-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.7); - z-index: 150; -} - -/* Désactiver hover sur tactile */ -@media (hover: none) { - .menu-item:hover { - background-color: transparent; - } -} - -/* Sélecteur de couleur */ -.color-picker-section { - margin-top: 0; - border-top: 2px solid #333; - padding: 1rem; - background-color: #0a0a0a; -} - -.color-picker-toggle { - width: 100%; - background-color: #1a1a1a; - border: 2px solid #333; - border-radius: 8px; - padding: 0.8rem 1rem; - display: flex; - align-items: center; - gap: 0.8rem; - cursor: pointer; - transition: all 0.2s; - -webkit-tap-highlight-color: transparent; -} - -.color-picker-toggle:active { - transform: scale(0.98); - background-color: #222; -} - -.color-preview { - width: 30px; - height: 30px; - border-radius: 50%; - border: 2px solid white; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); -} - -.color-picker-toggle span { - color: white; - font-size: 1rem; - flex: 1; -} - -.arrow { - color: white; - font-size: 0.8rem; - transition: transform 0.3s; -} - -.arrow.up { - transform: rotate(180deg); -} - -.color-options { - margin-top: 0.8rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - animation: slideDown 0.3s ease; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.color-option { - background-color: #0a0a0a; - border: 1px solid #333; - border-radius: 8px; - padding: 0.8rem 1rem; - display: flex; - align-items: center; - gap: 0.8rem; - cursor: pointer; - transition: all 0.2s; - -webkit-tap-highlight-color: transparent; -} - -.color-option:active { - transform: scale(0.98); - background-color: #1a1a1a; -} - -.color-option.active { - border-color: #4ade80; - background-color: #1a2a1a; -} - -.color-circle { - width: 25px; - height: 25px; - border-radius: 50%; - border: 2px solid white; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); -} - -.color-option span { - color: white; - font-size: 0.95rem; - flex: 1; -} - -.check { - color: #4ade80; - font-size: 1.2rem; - font-weight: bold; -} -/* Bouton panier flottant */ -.cart-button-float { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 1001; - width: 56px; - height: 56px; - background: linear-gradient( - 135deg, - rgba(124, 58, 237, 0.2), - rgba(109, 40, 217, 0.1) - ); - border: 1px solid rgba(124, 58, 237, 0.3); - border-radius: 50%; - color: #7c3aed; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - backdrop-filter: blur(10px); - box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2); -} - -.cart-button-float:hover { - background: linear-gradient( - 135deg, - rgba(124, 58, 237, 0.3), - rgba(109, 40, 217, 0.15) - ); - border-color: rgba(124, 58, 237, 0.5); - transform: scale(1.05); -} - -.cart-button-float:active { - transform: scale(0.95); -} - -.cart-icon-fa { - font-size: 1.5rem; -} - -.cart-count { - position: absolute; - top: -4px; - right: -4px; - background: linear-gradient(135deg, #ef4444, #dc2626); - color: white; - font-size: 0.75rem; - font-weight: 700; - min-width: 22px; - height: 22px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - padding: 2px; - box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4); - border: 2px solid #0f0f0f; -} - -/* Bouton Telegram */ -.telegram-button { width: 100%; display: flex; align-items: center; - justify-content: center; - gap: 0.8rem; - padding: 0.9rem 1rem; - background: linear-gradient( - 135deg, - rgba(37, 161, 244, 0.1), - rgba(32, 139, 220, 0.05) - ); - border: 1px solid rgba(37, 161, 244, 0.3); - border-radius: 12px; - color: #25a1f4; - font-size: 0.95rem; - font-weight: 600; + gap: 0.85rem; + padding: 0.7rem 0.9rem; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - margin-bottom: 0.5rem; + text-align: left; + transition: all var(--transition); + -webkit-tap-highlight-color: transparent; + position: relative; } -.telegram-button:hover:not(:disabled) { - background: linear-gradient( - 135deg, - rgba(37, 161, 244, 0.15), - rgba(32, 139, 220, 0.08) - ); - border-color: rgba(37, 161, 244, 0.5); - transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(37, 161, 244, 0.3); +.menu-item:hover:not(:disabled) { + background: var(--surface); + border-color: var(--border); + color: var(--text); } -.telegram-button:active:not(:disabled) { +.menu-item.active { + background: var(--primary-soft); + border-color: rgba(139, 92, 246, 0.2); + color: var(--primary); +} + +.menu-item:active:not(:disabled) { transform: scale(0.98); } -.telegram-button:disabled { - opacity: 0.5; +.menu-item:disabled { + opacity: 0.4; cursor: not-allowed; } -/* Footer styles */ +.menu-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--surface); + font-size: 0.85rem; + flex-shrink: 0; + transition: all var(--transition); +} + +.menu-item.active .menu-icon { + background: rgba(139, 92, 246, 0.2); + color: var(--primary); +} + +.menu-label { + flex: 1; +} + +.menu-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); + flex-shrink: 0; + box-shadow: 0 0 8px var(--primary-glow); +} + +/* ── Footer ── */ .sidebar-footer { - margin-top: auto; - padding: 1rem; - border-top: 1px solid #333; + padding: 0.75rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; +} + +.sidebar-footer-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 0.7rem 1rem; + border-radius: var(--radius); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + -webkit-tap-highlight-color: transparent; +} + +.sidebar-footer-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sidebar-footer-btn.telegram { + background: var(--cyan-soft); + border: 1px solid rgba(34, 211, 238, 0.2); + color: var(--cyan); +} + +.sidebar-footer-btn.telegram:hover:not(:disabled) { + background: rgba(34, 211, 238, 0.15); + border-color: rgba(34, 211, 238, 0.35); + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(34, 211, 238, 0.15); +} + +.sidebar-footer-btn.logout { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.18); + color: #f87171; +} + +.sidebar-footer-btn.logout:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.3); + transform: translateY(-1px); +} + +.sidebar-footer-btn:active:not(:disabled) { + transform: scale(0.97); +} + +/* ============================================================ + Notification panel + ============================================================ */ +.notif-panel { + position: absolute; + top: calc(var(--topbar-h) - 4px); + right: 0; + width: 300px; + max-height: 380px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + animation: slideDown 0.18s ease; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.notif-panel-header { + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--border); + color: var(--text); + font-weight: 600; + font-size: 0.85rem; + letter-spacing: 0.02em; +} + +.notif-empty { + padding: 1.5rem; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; +} + +.notif-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + max-height: 320px; +} + +.notif-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--border); + transition: background var(--transition); +} + +.notif-item:last-child { border-bottom: none; } + +.notif-unread { + background: var(--primary-soft); + border-left: 3px solid var(--primary); +} + +.notif-read { opacity: 0.55; } + +.notif-message { + color: var(--text); + font-size: 0.83rem; + line-height: 1.45; +} + +.notif-time { + color: var(--text-muted); + font-size: 0.72rem; +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (hover: none) { + .menu-item:hover { background: transparent; border-color: transparent; color: var(--text-muted); } + .menu-item.active:hover { background: var(--primary-soft); color: var(--primary); } + .topbar-toggle:hover { background: transparent; border-color: transparent; } + .topbar-icon-btn:hover { background: transparent; border-color: transparent; } } diff --git a/frontend-prep/src/components/Navbar.tsx b/frontend-prep/src/components/Navbar.tsx index 4182cd5c..24e9552a 100644 --- a/frontend-prep/src/components/Navbar.tsx +++ b/frontend-prep/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { useCart } from "../context/CartContext"; import "./Navbar.css"; @@ -12,10 +12,12 @@ import { faSignOutAlt, faBars, faTimes, - faChevronRight, + faBell, } from "@fortawesome/free-solid-svg-icons"; import { faTelegram } from "@fortawesome/free-brands-svg-icons"; import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { getClientNotifications, markNotificationsRead } from "../api/api"; +import type { ClientNotification } from "../api/api"; interface MenuItem { id: string; @@ -27,98 +29,98 @@ interface MenuItem { function Navbar() { const [isMenuOpen, setIsMenuOpen] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [showNotifPanel, setShowNotifPanel] = useState(false); + const seenKeysRef = useRef>(new Set()); + const isFirstLoadRef = useRef(true); + const notifPanelRef = useRef(null); const { cartCount } = useCart(); const navigate = useNavigate(); const location = useLocation(); + const fetchNotifications = useCallback(async () => { + const res = await getClientNotifications(); + if (!res.success) return; + setNotifications(res.notifications); + setUnreadCount(res.unread_count); + if (!isFirstLoadRef.current) { + for (const n of res.notifications) { + if (n.read) continue; + const key = `${n.command_id}-${n.type}-${n.created_at}`; + if (!seenKeysRef.current.has(key)) { + seenKeysRef.current.add(key); + } + } + } else { + for (const n of res.notifications) { + seenKeysRef.current.add(`${n.command_id}-${n.type}-${n.created_at}`); + } + isFirstLoadRef.current = false; + } + }, []); + + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, 15000); + return () => clearInterval(interval); + }, [fetchNotifications]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (notifPanelRef.current && !notifPanelRef.current.contains(e.target as Node)) { + setShowNotifPanel(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleNotifBellClick = async () => { + setShowNotifPanel((prev) => !prev); + if (!showNotifPanel && unreadCount > 0) { + await markNotificationsRead(); + setUnreadCount(0); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + } + }; + const menuItems: MenuItem[] = [ - { - id: "accueil", - label: "Accueil", - icon: faHome, - path: "/user/accueil", - }, - { - id: "produits", - label: "Nos Produits", - icon: faBox, - path: "/user/nos-produits", - }, - { - id: "panier", - label: "Passer Commande", - icon: faShoppingCart, - path: "/user/panier", - }, - { - id: "suivi", - label: "Suivi Livraison", - icon: faTruck, - path: "/user/suivi-livraison", - }, - { - id: "historique", - label: "Historique", - icon: faClockRotateLeft, - path: "/user/consultation-historique", - }, + { id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" }, + { id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" }, + { id: "panier", label: "Mon Panier", icon: faShoppingCart, path: "/user/panier" }, + { id: "suivi", label: "Suivi Livraison", icon: faTruck, path: "/user/suivi-livraison" }, + { id: "historique", label: "Historique", icon: faClockRotateLeft, path: "/user/consultation-historique" }, ]; - const toggleMenu = (): void => { - setIsMenuOpen(!isMenuOpen); - }; + const toggleMenu = () => setIsMenuOpen((v) => !v); + const closeMenu = () => setIsMenuOpen(false); - const closeMenu = (): void => { - setIsMenuOpen(false); - }; - - const handleNavigation = (path: string): void => { + const handleNavigation = (path: string) => { navigate(path); closeMenu(); }; - const handleLogout = async (): Promise => { + const handleLogout = async () => { if (isLoggingOut) return; setIsLoggingOut(true); - console.log("🚪 [LOGOUT] Déconnexion en cours..."); - try { const token = sessionStorage.getItem("admin_token"); - if (token) { try { - const response = await fetch( - "http://localhost:8080/api/v2/admin/auth/logout", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + await fetch("/api/v2/admin/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, - ); - - if (response.ok) { - console.log("✅ [LOGOUT] Déconnexion backend réussie"); - } else { - console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)"); - } - } catch (error) { - console.warn( - "⚠️ [LOGOUT] Erreur réseau backend (ignorée):", - error, - ); - } + }); + } catch (_) {} } - sessionStorage.removeItem("admin_token"); sessionStorage.removeItem("admin_username"); - console.log("✅ [LOGOUT] SessionStorage nettoyé"); - navigate("/login/client", { replace: true }); - } catch (error) { - console.error("❌ [LOGOUT] Erreur:", error); - + } catch (_) { sessionStorage.removeItem("admin_token"); sessionStorage.removeItem("admin_username"); navigate("/login/client", { replace: true }); @@ -127,86 +129,125 @@ function Navbar() { } }; - const handleTelegram = (): void => { - window.open("https://t.me/milieu_nantais", "_blank"); - }; + const handleTelegram = () => window.open("https://t.me/milieu_nantais", "_blank"); return ( <> - {/* Bouton Toggle */} - + {/* ── Top Bar ─────────────────────────────────── */} +
+ - {/* Overlay */} - {isMenuOpen && ( -
- )} + Milieu‑Nantais - {/* Bouton Panier (flottant en haut à droite) */} - +
+ {/* Notifications */} +
+ - {/* Sidebar */} + {showNotifPanel && ( +
+
Notifications
+ {notifications.length === 0 ? ( +
Aucune notification
+ ) : ( +
    + {notifications.map((n, i) => ( +
  • + {n.message} + + {new Date(n.created_at).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + })} + +
  • + ))} +
+ )} +
+ )} +
+ + {/* Panier */} + +
+
+ + {/* ── Overlay ─────────────────────────────────── */} + {isMenuOpen &&
} + + {/* ── Sidebar ─────────────────────────────────── */} diff --git a/frontend-prep/src/components/ProductCreateModal.tsx b/frontend-prep/src/components/ProductCreateModal.tsx deleted file mode 100644 index 67506307..00000000 --- a/frontend-prep/src/components/ProductCreateModal.tsx +++ /dev/null @@ -1,340 +0,0 @@ -// ============================================ -// components/admin/ProductCreateModal.tsx - VERSION AVEC BOUTONS -// ============================================ -// ✅ Boutons stylisés au lieu de select natif (comme AdminUsers) -// ✅ Pas de hover gris natif ! - -import React, { useState } from 'react'; -import { X, Plus, Trash2, Upload, DollarSign } from 'lucide-react'; -import { createProductAdmin } from '../api/api_admin'; -import type { CreateProductData, ProductPrice} from '../api/api_admin_types'; -import './ProductModal.css'; - -interface ProductCreateModalProps { - onClose: () => void; - onSuccess: () => void; -} - -const ProductCreateModal: React.FC = ({ onClose, onSuccess }) => { - // ============================================ - // 📝 STATE - // ============================================ - const [name, setName] = useState(''); - const [category, setCategory] = useState('weed&hash'); - const [description, setDescription] = useState(''); - const [stock, setStock] = useState(0); - const [prices, setPrices] = useState([ - { quantity: 1, price: 0 } - ]); - const [mediaFiles, setMediaFiles] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // ✅ Options de catégories - const categoryOptions = [ - { value: 'weed&hash', label: 'Weed & Hash' }, - { value: 'zipette&co', label: 'Zipette & Co' }, - { value: 'gros&semi', label: 'Gros & Semi' } - ]; - - // ============================================ - // 💰 GESTION DES PRIX - // ============================================ - const addPriceRow = () => { - setPrices([...prices, { quantity: 1, price: 0 }]); - }; - - const removePriceRow = (index: number) => { - if (prices.length > 1) { - setPrices(prices.filter((_, i) => i !== index)); - } - }; - - const updatePrice = (index: number, field: 'quantity' | 'price', value: number) => { - const newPrices = [...prices]; - newPrices[index][field] = value; - setPrices(newPrices); - }; - - // ============================================ - // 📁 GESTION DES FICHIERS - // ============================================ - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files) { - const newFiles = Array.from(e.target.files); - setMediaFiles([...mediaFiles, ...newFiles]); - console.log(`📁 ${newFiles.length} fichier(s) ajouté(s)`); - } - }; - - const removeFile = (index: number) => { - setMediaFiles(mediaFiles.filter((_, i) => i !== index)); - }; - - // ============================================ - // ✅ VALIDATION - // ============================================ - const validateForm = (): string | null => { - if (!name.trim()) return 'Le nom est requis'; - if (!description.trim()) return 'La description est requise'; - if (stock < 0) return 'Le stock ne peut pas être négatif'; - if (prices.length === 0) return 'Au moins un prix est requis'; - - for (const price of prices) { - if (price.quantity <= 0) return 'Toutes les quantités doivent être positives'; - if (price.price <= 0) return 'Tous les prix doivent être positifs'; - } - - return null; - }; - - // ============================================ - // 📤 SOUMISSION - // ============================================ - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const validationError = validateForm(); - if (validationError) { - setError(validationError); - return; - } - - try { - setLoading(true); - setError(null); - - console.log('📤 [CREATE_MODAL] Envoi du formulaire...'); - - const productData: CreateProductData = { - name: name.trim(), - category, - description: description.trim(), - stock, - prices, - }; - - const response = await createProductAdmin(productData, mediaFiles); - - if (response.success) { - console.log('✅ [CREATE_MODAL] Produit créé avec succès'); - onSuccess(); - } else { - setError(response.error || 'Erreur lors de la création du produit'); - } - } catch (error) { - console.error('❌ [CREATE_MODAL] Erreur:', error); - setError(error instanceof Error ? error.message : 'Erreur inconnue'); - } finally { - setLoading(false); - } - }; - - // ============================================ - // 🎨 RENDER - // ============================================ - return ( - <> -
-
- {/* Header */} -
-

Créer un Nouveau Produit

- -
- - {/* Content */} -
- {error && ( -
-

{error}

-
- )} - - {/* Informations de base */} -
-

Informations de Base

- -
- - setName(e.target.value)} - placeholder="Ex: OG Kush Premium" - required - /> -
- - {/* ✅ CATÉGORIE AVEC BOUTONS AU LIEU DE SELECT */} -
- -
- {categoryOptions.map(option => ( - - ))} -
-
- -
- -