diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go index dadf876d..6fe0847c 100644 --- a/backend/gestion/db/db_command_items.go +++ b/backend/gestion/db/db_command_items.go @@ -407,6 +407,51 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte return items, nil } +// ============================================ +// DELETE COMMAND ITEM - ADMIN ONLY +// ============================================ + +func (d *Database) DeleteCommandItem(commandID, itemID int) error { + log.Printf("đŸ—‘ïž [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) + + if err := validateCommandID(commandID); err != nil { + return err + } + if err := validateItemID(itemID); err != nil { + return err + } + + // RĂ©cupĂ©rer le prix et la quantitĂ© avant suppression pour mettre Ă  jour le total + var prix, quantite float64 + checkQuery := `SELECT prix, quantite FROM command_items WHERE id = $1 AND command_id = $2` + err := d.QueryRow(checkQuery, itemID, commandID).Scan(&prix, &quantite) + if err == sql.ErrNoRows { + return fmt.Errorf("item %d non trouvĂ© dans la commande %d", itemID, commandID) + } + if err != nil { + return fmt.Errorf("erreur vĂ©rification item: %w", err) + } + + // Supprimer l'item + _, err = d.Exec(`DELETE FROM command_items WHERE id = $1`, itemID) + if err != nil { + log.Printf("❌ Erreur DELETE command_items: %v", err) + return fmt.Errorf("erreur suppression item: %w", err) + } + + // Recalculer le total de la commande + _, err = d.Exec( + `UPDATE commandes SET total_prix = GREATEST(0, total_prix - $1) WHERE id = $2`, + prix*quantite, commandID, + ) + if err != nil { + log.Printf("⚠ [DeleteCommandItem] Erreur maj total commande: %v", err) + } + + log.Printf("✅ [DeleteCommandItem] Item %d supprimĂ© de la commande %d", itemID, commandID) + return nil +} + // ============================================ // UPDATE COMMAND ITEM STATUS - VERSION SÉCURISÉE // ============================================ diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 610a5bbf..5fe34cb1 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -48,6 +48,7 @@ func validateCommandStatus(status string) error { "pending": true, "assigned": true, "en_route": true, + "arrived": true, "livre": true, "approved": true, "cancelled": true, @@ -275,7 +276,9 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { - return nil, fmt.Errorf("stock insuffisant pour produit %d", item.ProductID) + // Le stock Ă©tait dĂ©jĂ  rĂ©servĂ© lors de l'ajout au panier (DecrementProductStockByID). + // On ne bloque pas la commande : tous les articles doivent ĂȘtre insĂ©rĂ©s. + log.Printf("⚠ [CHECKOUT] Stock dĂ©jĂ  rĂ©servĂ© pour produit %d (double rĂ©servation panier/checkout)", item.ProductID) } } @@ -324,7 +327,8 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa } query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix, - c.livreur_assign, c.created_at, c.updated_at + c.livreur_assign, c.created_at, c.updated_at, + c.proposed_address, c.address_proposal_status FROM commandes c WHERE 1=1` @@ -333,7 +337,7 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa // Filtrage du statut if status == "" { - query += ` AND c.status IN ('pending', 'assigned', 'en_route', 'livre')` + query += ` AND c.status IN ('pending', 'assigned', 'en_route', 'arrived', 'livre')` } else { query += fmt.Sprintf(" AND c.status = $%d", argPosition) args = append(args, status) @@ -362,10 +366,12 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa var id int var username, status, adresse string var livreurAssign sql.NullString + var proposedAddress sql.NullString + var addressProposalStatus string var totalPrix float64 var createdAt, updatedAt time.Time - err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt) + err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt, &proposedAddress, &addressProposalStatus) if err != nil { return nil, fmt.Errorf("erreur lors du scan de la commande: %w", err) } @@ -374,13 +380,14 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa adresse = sanitizeString(adresse) command := map[string]interface{}{ - "id": id, - "username": username, - "status": status, - "adresse": adresse, - "total_prix": totalPrix, - "created_at": createdAt, - "updated_at": updatedAt, + "id": id, + "username": username, + "status": status, + "adresse": adresse, + "total_prix": totalPrix, + "created_at": createdAt, + "updated_at": updatedAt, + "address_proposal_status": addressProposalStatus, } if livreurAssign.Valid { @@ -389,6 +396,12 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa command["livreur_assign"] = nil } + if proposedAddress.Valid { + command["proposed_address"] = proposedAddress.String + } else { + command["proposed_address"] = nil + } + commands = append(commands, command) } @@ -412,12 +425,15 @@ func (d *Database) GetCommandCount() (int, error) { func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) { // ✅ DĂ©jĂ  sĂ©curisĂ© avec paramĂštre $1 - query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at + query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at, + proposed_address, address_proposal_status FROM commandes WHERE id = $1` var commandID int var username, status, adresse string var livreurAssign sql.NullString + var proposedAddress sql.NullString + var addressProposalStatus string var totalPrix float64 var createdAt, updatedAt time.Time @@ -430,6 +446,8 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) { &livreurAssign, &createdAt, &updatedAt, + &proposedAddress, + &addressProposalStatus, ) if err == sql.ErrNoRows { @@ -440,13 +458,14 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) { } command := map[string]interface{}{ - "id": commandID, - "username": username, - "status": status, - "adresse": adresse, - "total_prix": totalPrix, - "created_at": createdAt, - "updated_at": updatedAt, + "id": commandID, + "username": username, + "status": status, + "adresse": adresse, + "total_prix": totalPrix, + "created_at": createdAt, + "updated_at": updatedAt, + "address_proposal_status": addressProposalStatus, } if livreurAssign.Valid { @@ -455,6 +474,12 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) { command["livreur_assign"] = nil } + if proposedAddress.Valid { + command["proposed_address"] = proposedAddress.String + } else { + command["proposed_address"] = nil + } + return command, nil } @@ -503,10 +528,74 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e return nil } +// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client +func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error { + if err := validateAddress(proposedAddress); err != nil { + return err + } + + query := `UPDATE commandes + SET proposed_address = $1, address_proposal_status = 'pending', updated_at = CURRENT_TIMESTAMP + WHERE id = $2` + + result, err := d.Exec(query, proposedAddress, commandID) + if err != nil { + return fmt.Errorf("erreur proposition adresse: %w", err) + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("commande non trouvĂ©e") + } + + d.AddCommandLog(commandID, "address_proposed", + fmt.Sprintf("Nouvelle adresse proposĂ©e par %s: %s", proposedBy, proposedAddress), + proposedBy) + + log.Printf("✅ Adresse proposĂ©e pour commande %d par %s", commandID, proposedBy) + return nil +} + +// RespondToAddressProposal accepte ou refuse la proposition d'adresse +func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error { + var query string + if accepted { + // Remplace l'adresse par la proposition + query = `UPDATE commandes + SET adresse = proposed_address, proposed_address = NULL, + address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND username = $2 AND address_proposal_status = 'pending'` + } else { + query = `UPDATE commandes + SET proposed_address = NULL, address_proposal_status = 'rejected', + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND username = $2 AND address_proposal_status = 'pending'` + } + + result, err := d.Exec(query, commandID, clientUsername) + if err != nil { + return fmt.Errorf("erreur rĂ©ponse proposition adresse: %w", err) + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("aucune proposition en attente pour cette commande") + } + + action := "refusĂ©e" + if accepted { + action = "acceptĂ©e" + } + d.AddCommandLog(commandID, "address_proposal_"+action, + fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername), + clientUsername) + + log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID) + return nil +} + // UpdateCommandStatus met Ă  jour le statut d'une commande func (d *Database) UpdateCommandStatus(commandID int, status string) error { // ✅ SÉCURITÉ: Validation du statut - validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"} + validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"} isValid := false for _, vs := range validStatuses { if status == vs { @@ -617,7 +706,7 @@ func (d *Database) GetCommandsWithFilter(status, username string, excludeApprove // ✅ Filtrer par status si fourni avec validation if status != "" { - validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"} + validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"} isValid := false for _, vs := range validStatuses { if status == vs { diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index a5b95829..a6c29b2a 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -86,6 +86,14 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration push_token users: %v", err) } + // Migration: proposition de modification d'adresse par admin/cabine + if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil { + log.Fatalf("❌ Erreur migration proposed_address: %v", err) + } + if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil { + log.Fatalf("❌ Erreur migration address_proposal_status: %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) diff --git a/backend/gestion/handlers/client_tracking.go b/backend/gestion/handlers/client_tracking.go index ac75ac10..386291ba 100644 --- a/backend/gestion/handlers/client_tracking.go +++ b/backend/gestion/handlers/client_tracking.go @@ -131,14 +131,16 @@ func GetMyCommandsWithTracking(c *gin.Context) { } enrichedCommands[i] = gin.H{ - "id": cmd["id"], - "status": cmd["status"], - "status_message": getStatusMessage(cmd["status"].(string)), - "adresse": cmd["adresse"], - "total_prix": cmd["total_prix"], - "created_at": cmd["created_at"], - "livreur": livreurInfo, - "eta": etaData, + "id": cmd["id"], + "status": cmd["status"], + "status_message": getStatusMessage(cmd["status"].(string)), + "adresse": cmd["adresse"], + "total_prix": cmd["total_prix"], + "created_at": cmd["created_at"], + "livreur": livreurInfo, + "eta": etaData, + "proposed_address": cmd["proposed_address"], + "address_proposal_status": cmd["address_proposal_status"], } } diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index f98d48cc..179da290 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -156,6 +156,120 @@ func UpdateCommandAddress(c *gin.Context) { }) } +// ProposeAddressChange propose une nouvelle adresse au client pour validation +// POST /api/v2/admin/protected/orders/:id/propose-address +// POST /api/v1/cabine/commands/:id/propose-address +func ProposeAddressChange(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + userRole := c.GetString("role") + if userRole != "admin" && userRole != "cabine" { + c.JSON(http.StatusForbidden, gin.H{"error": "AccĂšs refusĂ©"}) + return + } + + staffUsername, err := safeGetUsername(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil || commandID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) + return + } + + var req struct { + ProposedAddress string `json:"proposed_address" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "DonnĂ©es invalides"}) + return + } + + if err := validateAddress(req.ProposedAddress); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // RĂ©cupĂ©rer la commande pour vĂ©rifier statut et obtenir username client + command, err := database.GetCommandByID(commandID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvĂ©e"}) + return + } + + status, _ := command["status"].(string) + if status == "livre" || status == "approved" || status == "cancelled" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de modifier l'adresse d'une commande terminĂ©e"}) + return + } + + if err := database.ProposeAddressChange(commandID, req.ProposedAddress, staffUsername); err != nil { + log.Printf("❌ [PROPOSE_ADDR] Erreur: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Notifier le client + clientUsername, _ := command["username"].(string) + if clientUsername != "" { + msg := fmt.Sprintf("📍 Une nouvelle adresse de livraison vous est proposĂ©e pour la commande #%d : %s. Veuillez l'accepter ou la refuser dans le suivi de commande.", commandID, req.ProposedAddress) + database.NotifyClient(clientUsername, commandID, "address_proposal", msg) + } + + log.Printf("✅ [PROPOSE_ADDR] Commande %d - nouvelle adresse proposĂ©e par %s", commandID, staffUsername) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Nouvelle adresse proposĂ©e au client", + "command_id": commandID, + }) +} + +// RespondToAddressProposal permet au client d'accepter ou refuser une proposition d'adresse +// POST /api/v1/commands/:id/address/respond +func RespondToAddressProposal(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + clientUsername, err := safeGetUsername(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) + return + } + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil || commandID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) + return + } + + var req struct { + Accepted bool `json:"accepted"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "DonnĂ©es invalides"}) + return + } + + if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil { + log.Printf("❌ [RESPOND_ADDR] Erreur: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + action := "refusĂ©e" + if req.Accepted { + action = "acceptĂ©e" + } + + log.Printf("✅ [RESPOND_ADDR] Commande %d - proposition %s par %s", commandID, action, clientUsername) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("Proposition d'adresse %s", action), + }) +} + func GetAllCommands(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -768,6 +882,54 @@ func GetClientCommandsHistory(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// ============================================ +// NOTIFICATIONS CLIENT +// ============================================ + +// NotifyClientToDescend envoie une notification push au client pour descendre rĂ©cupĂ©rer sa commande +// POST /api/v2/admin/protected/orders/:id/notify-client +// POST /api/v1/cabine/commands/:id/notify-client +func NotifyClientToDescend(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + role := c.GetString("role") + if role != "admin" && role != "cabine" { + c.JSON(http.StatusForbidden, gin.H{"error": "AccĂšs refusĂ©"}) + return + } + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil || commandID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) + return + } + + cmd, err := database.GetCommandByID(commandID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"}) + return + } + + clientUsername, ok := cmd["username"].(string) + if !ok || clientUsername == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Client introuvable"}) + return + } + + staffUsername, _ := c.Get("username") + msg := fmt.Sprintf("Votre commande #%d est prĂȘte ! Vous pouvez descendre la rĂ©cupĂ©rer.", commandID) + database.NotifyClient(clientUsername, commandID, "ready_pickup", msg) + database.AddCommandLog(commandID, "notification", fmt.Sprintf("Client notifiĂ© de descendre par %s", staffUsername), staffUsername.(string)) + + log.Printf("🔔 [NOTIFY] Client %s notifiĂ© pour commande %d par %s", clientUsername, commandID, staffUsername) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Client notifiĂ©", + "client_username": clientUsername, + }) +} + // ============================================ // DÉTAILS COMMANDES & ITEMS // ============================================ @@ -1145,6 +1307,104 @@ func GetCommandFullDetails(c *gin.Context) { }) } +// DeleteCommandItem supprime un item d'une commande +// DELETE /api/v2/admin/protected/orders/:id/items/:item_id +func DeleteCommandItem(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + if c.GetString("role") != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "AccĂšs refusĂ©"}) + return + } + + adminUsername, err := safeGetUsername(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil || commandID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) + return + } + + itemID, err := strconv.Atoi(c.Param("item_id")) + if err != nil || itemID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'item invalide"}) + return + } + + log.Printf("đŸ—‘ïž [DEL_ITEM] Admin %s supprime item %d de cmd %d", adminUsername, itemID, commandID) + + if err := database.DeleteCommandItem(commandID, itemID); err != nil { + log.Printf("❌ [DEL_ITEM] Erreur: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + database.AddCommandLog(commandID, "item_deleted", + fmt.Sprintf("Item %d supprimĂ© par admin %s", itemID, adminUsername), + adminUsername) + + log.Printf("✅ [DEL_ITEM] Item %d supprimĂ©", itemID) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Item supprimĂ©", + "command_id": commandID, + "item_id": itemID, + }) +} + +// UpdateCommandStatusAdmin met Ă  jour le statut d'une commande (Admin) +// PUT /api/v2/admin/protected/orders/:id/status +func UpdateCommandStatusAdmin(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + if c.GetString("role") != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "AccĂšs refusĂ©"}) + return + } + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil || commandID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) + return + } + + var req struct { + Status string `json:"status" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Statut manquant"}) + return + } + + allowed := map[string]bool{ + "pending": true, "assigned": true, "en_route": true, + "livre": true, "approved": true, "cancelled": true, + } + if !allowed[req.Status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "Statut invalide: " + req.Status}) + return + } + + if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { + log.Printf("❌ [STATUS_ADMIN] Erreur: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + log.Printf("✅ [STATUS_ADMIN] Cmd %d → %s", commandID, req.Status) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Statut mis Ă  jour", + "command_id": commandID, + "new_status": req.Status, + }) +} + // GetCommandItemsStats rĂ©cupĂšre les stats d'une commande // GET /api/v1/commands/:id/stats func GetCommandItemsStats(c *gin.Context) { diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index a08af832..7df69e5f 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -373,7 +373,7 @@ func UpdateDeliveryStatus(c *gin.Context) { clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID) } case "arrived": - clientMsg = fmt.Sprintf("Votre livreur est arrivĂ© pour la commande #%d", commandID) + clientMsg = fmt.Sprintf("đŸ›” Votre livreur est lĂ  ! Il sera chez vous dans 5 minutes (commande #%d)", commandID) case "livre": clientMsg = fmt.Sprintf("Votre commande #%d a Ă©tĂ© livrĂ©e. Merci !", commandID) case "failed": diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 2c6f7785..c18c84fb 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -104,7 +104,7 @@ func main() { // Configuration CORS r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173"}, + AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 8468f979..21871bb6 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -79,6 +79,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cartGroupV1.GET("/commands/:id/items", handlers.GetCommandItemsWithDetails) // Approbation livraison cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery) + cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal) // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems) cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory) @@ -167,8 +168,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.GET("/orders", handlers.GetAllCommands) adminGroupV2.GET("/orders/:id", handlers.GetCommandByID) adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress) + adminGroupV2.POST("/orders/:id/propose-address", handlers.ProposeAddressChange) + adminGroupV2.PUT("/orders/:id/status", handlers.UpdateCommandStatusAdmin) adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery) adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery) + adminGroupV2.POST("/orders/:id/notify-client", handlers.NotifyClientToDescend) + adminGroupV2.GET("/orders/:id/items", handlers.ShowItems) + adminGroupV2.DELETE("/orders/:id/items/:item_id", handlers.DeleteCommandItem) + adminGroupV2.DELETE("/orders/:id", handlers.DeleteCommandByCabine) // ============================================ // ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES @@ -237,10 +244,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems) cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery) cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson) + cabineGroupV1.POST("/commands/:id/notify-client", handlers.NotifyClientToDescend) cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus) cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand) cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen) cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine) + cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange) // ⭐ NOUVEAU - ANNULATION PAR CABINE cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders) cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pĂ©nalitĂ©s client diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index f7912d19..0fa8fc60 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -173,6 +173,14 @@ export const validateCommand = async (commandId: number) => { return { success: true, message: data.message }; }; +export const proposeAddressChangeAdmin = async (commandId: number, proposedAddress: string) => { + const { data } = await apiClient.post( + `${V2}/admin/protected/orders/${commandId}/propose-address`, + { proposed_address: proposedAddress }, + ); + return { success: true, message: data.message }; +}; + export const notifyClientToDescend = async (commandId: number) => { const { data } = await apiClient.post( `${V2}/admin/protected/orders/${commandId}/notify-client`, diff --git a/frontend-admin/src/api/api_cabine.ts b/frontend-admin/src/api/api_cabine.ts index 82404ff6..dc3ee2b8 100644 --- a/frontend-admin/src/api/api_cabine.ts +++ b/frontend-admin/src/api/api_cabine.ts @@ -117,6 +117,14 @@ export const deleteCommand = async (commandId: number) => { return { success: true, message: data.message }; }; +export const proposeAddressChangeCabine = async (commandId: number, proposedAddress: string) => { + const { data } = await apiClient.post( + `${API}/commands/${commandId}/propose-address`, + { proposed_address: proposedAddress }, + ); + return { success: true, message: data.message }; +}; + export const notifyClientToDescendCabine = async (commandId: number) => { const { data } = await apiClient.post( `${API}/commands/${commandId}/notify-client`, diff --git a/frontend-admin/src/screens/admin/OrdersScreen.tsx b/frontend-admin/src/screens/admin/OrdersScreen.tsx index b351d8db..129c8962 100644 --- a/frontend-admin/src/screens/admin/OrdersScreen.tsx +++ b/frontend-admin/src/screens/admin/OrdersScreen.tsx @@ -7,6 +7,7 @@ import { TouchableOpacity, RefreshControl, ScrollView, + TextInput, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useNavigation } from "@react-navigation/native"; @@ -23,6 +24,7 @@ import { confirmReceptionAdmin, deleteCommand, deleteCommandItem, + proposeAddressChangeAdmin, } from "../../api/api_admin"; import type { CommandResponse } from "../../api/types"; import type { AdminStackParamList } from "../../navigation/types"; @@ -62,6 +64,12 @@ export default function OrdersScreen() { }>({ visible: false, commandId: null }); const [livreurs, setLivreurs] = useState([]); + const [addressModal, setAddressModal] = useState<{ + visible: boolean; + commandId: number | null; + input: string; + }>({ visible: false, commandId: null, input: "" }); + const [itemsModal, setItemsModal] = useState<{ visible: boolean; commandId: number | null; @@ -180,6 +188,17 @@ export default function OrdersScreen() { ); }; + const handleProposeAddress = async () => { + if (!addressModal.commandId || !addressModal.input.trim()) return; + try { + await proposeAddressChangeAdmin(addressModal.commandId, addressModal.input.trim()); + setAddressModal({ visible: false, commandId: null, input: "" }); + showSuccess("Proposition envoyĂ©e", "Le client a Ă©tĂ© notifiĂ© de la nouvelle adresse proposĂ©e"); + } catch (e: any) { + showError("Erreur", e.message); + } + }; + const handleDeleteItem = (commandId: number, itemId: number, itemName: string) => { showConfirm( "Supprimer l'article", @@ -372,6 +391,28 @@ export default function OrdersScreen() { padding: spacing.xs, marginLeft: spacing.s, }, + addressInput: { + backgroundColor: colors.bgPrimary, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.sm, + color: colors.textWhite, + paddingHorizontal: spacing.m, + paddingVertical: spacing.m, + fontSize: fontSize.md, + marginBottom: spacing.m, + }, + addressConfirmBtn: { + backgroundColor: colors.accent, + borderRadius: borderRadius.sm, + paddingVertical: spacing.m, + alignItems: "center", + }, + addressConfirmText: { + color: colors.textWhite, + fontSize: fontSize.md, + fontWeight: "700", + }, }), [colors], ); @@ -394,6 +435,12 @@ export default function OrdersScreen() { icon: "receipt-outline" as keyof typeof Ionicons.glyphMap, onPress: () => { setOpenMenuId(null); openItems(item.id); }, }, + { + label: "Proposer adresse", + icon: "location-outline" as keyof typeof Ionicons.glyphMap, + onPress: () => { setOpenMenuId(null); setAddressModal({ visible: true, commandId: item.id, input: "" }); }, + condition: !isDone, + }, { label: "Le client est lĂ ", icon: "notifications-outline" as keyof typeof Ionicons.glyphMap, @@ -649,6 +696,29 @@ export default function OrdersScreen() { )} + {/* Modal proposition adresse */} + setAddressModal({ visible: false, commandId: null, input: "" })} + title={`Proposer adresse — commande #${addressModal.commandId}`} + icon="location-outline" + > + setAddressModal((prev) => ({ ...prev, input: t }))} + multiline + /> + + Envoyer la proposition + + + ({ visible: false, commandId: null }); + const [addressModal, setAddressModal] = useState<{ + visible: boolean; + commandId: number | null; + input: string; + }>({ visible: false, commandId: null, input: "" }); const [livreurs, setLivreurs] = useState<{ id: number; username: string }[]>([]); const loadData = useCallback(async () => { @@ -148,6 +155,17 @@ export default function OrdersScreen() { } }; + const handleProposeAddress = async () => { + if (!addressModal.commandId || !addressModal.input.trim()) return; + try { + await proposeAddressChangeCabine(addressModal.commandId, addressModal.input.trim()); + setAddressModal({ visible: false, commandId: null, input: "" }); + showSuccess("Proposition envoyĂ©e", "Le client a Ă©tĂ© notifiĂ© de la nouvelle adresse proposĂ©e"); + } catch (e: any) { + showError("Erreur", e.message); + } + }; + const handleResetPoints = (clientUsername: string) => { setOpenMenuId(null); showConfirm( @@ -283,6 +301,28 @@ export default function OrdersScreen() { color: colors.danger, fontSize: fontSize.sm, }, + addressInput: { + backgroundColor: colors.bgPrimary, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.sm, + color: colors.textWhite, + paddingHorizontal: spacing.m, + paddingVertical: spacing.m, + fontSize: fontSize.md, + marginBottom: spacing.m, + }, + addressConfirmBtn: { + backgroundColor: colors.accent, + borderRadius: borderRadius.sm, + paddingVertical: spacing.m, + alignItems: "center", + }, + addressConfirmText: { + color: colors.textWhite, + fontSize: fontSize.md, + fontWeight: "700", + }, // Modal summary modalSummary: { backgroundColor: colors.bgCard, @@ -396,6 +436,11 @@ export default function OrdersScreen() { icon: "receipt-outline" as keyof typeof Ionicons.glyphMap, onPress: () => openItems(item.id), }, + { + label: "Proposer adresse", + icon: "location-outline" as keyof typeof Ionicons.glyphMap, + onPress: () => { setOpenMenuId(null); setAddressModal({ visible: true, commandId: item.id, input: "" }); }, + }, { label: "Le client est lĂ ", icon: "notifications-outline" as keyof typeof Ionicons.glyphMap, @@ -622,6 +667,29 @@ export default function OrdersScreen() { )} + {/* Modal proposition adresse */} + setAddressModal({ visible: false, commandId: null, input: "" })} + title={`Proposer adresse — commande #${addressModal.commandId}`} + icon="location-outline" + > + setAddressModal((prev) => ({ ...prev, input: t }))} + multiline + /> + + Envoyer la proposition + + +