diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 12b50c2f..5cb3dbec 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -513,6 +513,20 @@ func (db *Database) createTables() error { id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL );`, + + // ============================ + // TABLE livreur_ratings + // ============================ + `CREATE TABLE IF NOT EXISTS livreur_ratings ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE, + livreur_username VARCHAR(255) NOT NULL, + client_username VARCHAR(255) NOT NULL, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + comment TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`, } for _, query := range queries { diff --git a/backend/gestion/db/db_livreur_rating.go b/backend/gestion/db/db_livreur_rating.go new file mode 100644 index 00000000..a44c51ef --- /dev/null +++ b/backend/gestion/db/db_livreur_rating.go @@ -0,0 +1,62 @@ +package db + +import ( + "time" +) + +type LivreurRating struct { + ID int `json:"id"` + OrderID int `json:"order_id"` + LivreurUsername string `json:"livreur_username"` + ClientUsername string `json:"client_username"` + Rating int `json:"rating"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` +} + +func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error { + return d.GDB.Exec(` + INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at) + VALUES (?, ?, ?, ?, ?, NOW()) + `, orderID, livreurUsername, clientUsername, rating, comment).Error +} + +func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) { + var r LivreurRating + err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error + if err != nil { + return nil, err + } + if r.ID == 0 { + return nil, nil + } + return &r, nil +} + +func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) { + var ratings []LivreurRating + if err := d.GDB.Raw(` + SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC + `, livreurUsername).Scan(&ratings).Error; err != nil { + return nil, 0, err + } + + var avg float64 + if len(ratings) > 0 { + d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg) + } + return ratings, avg, nil +} + +// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée +func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) { + var row struct { + Username string `gorm:"column:username"` + LivreurAssign string `gorm:"column:livreur_assign"` + } + err = d.GDB.Raw(` + SELECT username, COALESCE(livreur_assign, '') as livreur_assign + FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1 + `, orderID).Scan(&row).Error + return row.Username, row.LivreurAssign, err +} diff --git a/backend/gestion/handlers/rating.go b/backend/gestion/handlers/rating.go new file mode 100644 index 00000000..d4b1c075 --- /dev/null +++ b/backend/gestion/handlers/rating.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "gestion/db" + "gestion/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func SubmitLivreurRating(c *gin.Context) { + clientUsername := c.GetString("username") + if clientUsername == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + orderID, err := strconv.Atoi(c.Param("id")) + if err != nil || orderID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"}) + return + } + + var req struct { + Rating int `json:"rating" binding:"required,min=1,max=5"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"}) + return + } + + database := c.MustGet("database").(*db.Database) + + ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur lecture commande", err) + return + } + if ownerUsername == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"}) + return + } + if ownerUsername != clientUsername { + c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"}) + return + } + if livreurUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"}) + return + } + + existing, err := database.GetOrderRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur vérification avis", err) + return + } + if existing != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"}) + return + } + + if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil { + utils.ServerErr(c, "Erreur enregistrement avis", err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func GetLivreurRatings(c *gin.Context) { + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"}) + return + } + + database := c.MustGet("database").(*db.Database) + ratings, avg, err := database.GetLivreurRatings(username) + if err != nil { + utils.ServerErr(c, "Erreur récupération avis", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ratings": ratings, + "average": avg, + "count": len(ratings), + }) +} + +func GetOrderRatingStatus(c *gin.Context) { + clientUsername := c.GetString("username") + if clientUsername == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + orderID, err := strconv.Atoi(c.Param("id")) + if err != nil || orderID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"}) + return + } + + database := c.MustGet("database").(*db.Database) + rating, err := database.GetOrderRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur", err) + return + } + + if rating == nil { + c.JSON(http.StatusOK, gin.H{"rated": false}) + return + } + c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment}) +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index cd3121d4..d514fcd2 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -88,6 +88,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory) + // NOTATION LIVREUR + cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating) + cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus) + // ⭐⭐ PÉNALITÉS CLIENT cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités @@ -260,6 +264,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin) adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue) adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks) + adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings) // Commandes annulées adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders) // ============================================ diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index e28093f4..edad1337 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -299,6 +299,21 @@ export const getDeliveryPersonDetails = async (username: string) => { return data.deliveryman || data; }; +export const getLivreurRatings = async (username: string): Promise<{ + ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[]; + average: number; + count: number; +}> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/delivery-persons/${username}/ratings`, + ); + return data; + } catch { + return { ratings: [], average: 0, count: 0 }; + } +}; + const parseStatus = (status: any): "available" | "busy" | "offline" => { if (!status) return "offline"; if (status === "available" || status === "busy" || status === "offline") diff --git a/frontend-admin/src/screens/admin/DeliveryScreen.tsx b/frontend-admin/src/screens/admin/DeliveryScreen.tsx index f23028e3..c8048f6e 100644 --- a/frontend-admin/src/screens/admin/DeliveryScreen.tsx +++ b/frontend-admin/src/screens/admin/DeliveryScreen.tsx @@ -15,6 +15,8 @@ import { Modal, StatusBar, useWindowDimensions, + ScrollView, + Pressable, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; @@ -22,6 +24,7 @@ import { useTheme } from "../../context/ThemeContext"; import { getAllDeliveryPersonsWithDetails, getCommandByID, + getLivreurRatings, } from "../../api/api_admin"; import { geocodeAddress, calculateRoute } from "../../api/tomtom"; import type { RouteInfo } from "../../api/tomtom"; @@ -61,6 +64,22 @@ export default function DeliveryScreen() { const [routeInfo, setRouteInfo] = useState(null); const [routeLoading, setRouteLoading] = useState(false); + // Avis livreur + const [ratingsModal, setRatingsModal] = useState<{ + username: string; + ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[]; + average: number; + count: number; + } | null>(null); + const [ratingsLoading, setRatingsLoading] = useState(false); + + const openRatings = async (username: string) => { + setRatingsLoading(true); + const data = await getLivreurRatings(username); + setRatingsModal({ username, ...data }); + setRatingsLoading(false); + }; + const loadData = useCallback(async () => { try { const result = await getAllDeliveryPersonsWithDetails(); @@ -338,6 +357,24 @@ export default function DeliveryScreen() { fontWeight: "500", }, + ratingsBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.s, + marginTop: spacing.m, + paddingVertical: spacing.s, + paddingHorizontal: spacing.m, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: "#f59e0b", + }, + ratingsBtnText: { + color: "#f59e0b", + fontSize: fontSize.sm, + fontWeight: "600", + }, + trackBtn: { flexDirection: "row", alignItems: "center", @@ -356,6 +393,80 @@ export default function DeliveryScreen() { fontWeight: "600", }, + ratingsOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.7)", + justifyContent: "flex-end", + }, + ratingsSheet: { + backgroundColor: colors.bgCard, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + padding: spacing.l, + maxHeight: "80%", + }, + ratingsHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: spacing.s, + }, + ratingsTitle: { + color: colors.textWhite, + fontSize: fontSize.lg, + fontWeight: "700", + }, + ratingsAvg: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginBottom: spacing.l, + }, + ratingsAvgText: { + color: "#f59e0b", + fontSize: fontSize.md, + fontWeight: "700", + }, + ratingsCount: { + color: colors.textMuted, + fontSize: fontSize.sm, + }, + ratingItem: { + borderTopWidth: 1, + borderTopColor: colors.borderLight, + paddingVertical: spacing.m, + }, + ratingItemHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: spacing.xs, + }, + ratingItemClient: { + color: colors.textPrimary, + fontSize: fontSize.sm, + fontWeight: "600", + }, + ratingItemDate: { + color: colors.textMuted, + fontSize: fontSize.xs, + }, + ratingStarsRow: { + flexDirection: "row", + gap: 2, + marginBottom: spacing.xs, + }, + ratingItemComment: { + color: colors.textSecondary, + fontSize: fontSize.sm, + fontStyle: "italic", + }, + ratingsEmpty: { + color: colors.textMuted, + textAlign: "center", + paddingVertical: spacing.xl, + }, + empty: { color: colors.textMuted, textAlign: "center", @@ -523,6 +634,15 @@ export default function DeliveryScreen() { )} + openRatings(item.username)} + activeOpacity={0.7} + > + + Voir les avis + + {hasGPS && ( Aucun livreur } /> + + {/* ── Modal avis livreur ── */} + setRatingsModal(null)} + > + setRatingsModal(null)}> + {}}> + + + + Avis — {ratingsModal?.username} + + setRatingsModal(null)}> + + + + {ratingsLoading ? ( + Chargement... + ) : ratingsModal && ratingsModal.count > 0 ? ( + <> + + {[1,2,3,4,5].map((s) => ( + + ))} + + {ratingsModal.average.toFixed(1)} + + + ({ratingsModal.count} avis) + + + + {ratingsModal.ratings.map((r) => ( + + + {r.client_username} + + {new Date(r.created_at).toLocaleDateString("fr-FR")} + + + + {[1,2,3,4,5].map((s) => ( + + ))} + + {r.comment !== "" && ( + "{r.comment}" + )} + + ))} + + + ) : ( + Aucun avis pour ce livreur + )} + + + + ); } diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 63d18acc..be7c0ff7 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -2201,6 +2201,49 @@ export const getMyPointsRewards = async (): Promise<{ } }; +export const submitLivreurRating = async ( + orderId: number, + rating: number, + comment: string, +): Promise<{ success: boolean; error?: string }> => { + const token = getAuthToken(); + if (!token) return { success: false, error: "Non authentifié" }; + try { + const response = await fetch(`${API_URL}/orders/${orderId}/rate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ rating, comment }), + }); + if (!response.ok) { + const data = await safeJson(response); + return { success: false, error: data.error || "Erreur" }; + } + return { success: true }; + } catch { + return { success: false, error: "Erreur de connexion" }; + } +}; + +export const getOrderRatingStatus = async ( + orderId: number, +): Promise<{ rated: boolean; rating?: number; comment?: string }> => { + const token = getAuthToken(); + if (!token) return { rated: false }; + try { + const response = await fetch(`${API_URL}/orders/${orderId}/rating`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) return { rated: false }; + const data = await safeJson(response); + return data; + } catch { + return { rated: false }; + } +}; + export const claimMyReward = async (poolKey: string): Promise<{ success: boolean; description?: string; diff --git a/frontend-prep/src/pages/User/OrderDetails.css b/frontend-prep/src/pages/User/OrderDetails.css index cd67a1b7..844666a1 100644 --- a/frontend-prep/src/pages/User/OrderDetails.css +++ b/frontend-prep/src/pages/User/OrderDetails.css @@ -802,6 +802,148 @@ animation-delay: 0.4s; } +/* ============================================ + RATING LIVREUR + ============================================ */ + +.rating-btn { + display: flex; + align-items: center; + gap: 0.4rem; + background: #f59e0b; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.75rem; + transition: opacity 0.2s; +} + +.rating-btn:hover { + opacity: 0.85; +} + +.rating-done-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(245, 158, 11, 0.1); + border-radius: 6px; +} + +.rating-done-text { + color: #f59e0b; + font-size: 0.85rem; +} + +.rating-star-filled { + color: #f59e0b; +} + +.rating-star-empty { + color: #6b7280; +} + +.rating-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 1000; +} + +.rating-modal-sheet { + background: var(--surface, #1a1a2e); + border-radius: 16px 16px 0 0; + padding: 1.5rem 1.5rem 2.5rem; + width: 100%; + max-width: 500px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rating-modal-title { + color: var(--text-primary, #fff); + font-size: 1.1rem; + font-weight: 700; + margin: 0; +} + +.rating-modal-subtitle { + color: var(--text-muted, #9ca3af); + font-size: 0.875rem; + margin: 0; +} + +.rating-stars-row { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + +.rating-star-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + line-height: 0; +} + +.rating-comment-input { + width: 100%; + background: var(--bg, #0f0f1a); + border: 1px solid var(--border, #374151); + border-radius: 6px; + color: var(--text-primary, #fff); + padding: 0.75rem; + font-size: 0.875rem; + resize: vertical; + box-sizing: border-box; +} + +.rating-comment-input::placeholder { + color: var(--text-muted, #9ca3af); +} + +.rating-submit-btn { + background: #f59e0b; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.rating-submit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.rating-submit-btn:not(:disabled):hover { + opacity: 0.85; +} + +.rating-cancel-btn { + background: none; + border: none; + color: var(--text-muted, #9ca3af); + font-size: 0.875rem; + cursor: pointer; + padding: 0.5rem; + text-align: center; +} + /* ============================================ PRINT STYLES ============================================ */ diff --git a/frontend-prep/src/pages/User/OrderDetails.tsx b/frontend-prep/src/pages/User/OrderDetails.tsx index e0f00f42..27bcaaa3 100644 --- a/frontend-prep/src/pages/User/OrderDetails.tsx +++ b/frontend-prep/src/pages/User/OrderDetails.tsx @@ -9,6 +9,8 @@ import { isUserAuthenticated, getProductById, getMediaUrl, + submitLivreurRating, + getOrderRatingStatus, } from "../../api/api"; import type { CompletedOrder, Product } from "../../api/api_types"; import { @@ -26,6 +28,7 @@ import { ShoppingBag, Camera, X, + Star, } from "lucide-react"; interface OrderProduct { @@ -73,6 +76,11 @@ function OrderDetails() { const [error, setError] = useState(""); const [showVideo, setShowVideo] = useState(false); const [currentVideoUrl, setCurrentVideoUrl] = useState(""); + const [ratingModal, setRatingModal] = useState(false); + const [selectedStars, setSelectedStars] = useState(0); + const [ratingComment, setRatingComment] = useState(""); + const [ratingSubmitting, setRatingSubmitting] = useState(false); + const [ratingDone, setRatingDone] = useState<{ rating: number; comment: string } | null>(null); // ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION useEffect(() => { @@ -315,6 +323,11 @@ function OrderDetails() { console.log("✅ [ORDER DETAILS] Données mappées:", orderData); setOrder(orderData); + + if (apiData.command_info?.command_status === "approved" && apiData.command_info?.livreur_assign) { + const r = await getOrderRatingStatus(id); + if (r.rated) setRatingDone({ rating: r.rating!, comment: r.comment ?? "" }); + } } else { console.error("❌ [ORDER DETAILS] Erreur:", result.message); setError(result.message || "Erreur lors du chargement"); @@ -327,6 +340,17 @@ function OrderDetails() { } }; + const handleSubmitRating = async () => { + if (selectedStars === 0 || !order) return; + setRatingSubmitting(true); + const res = await submitLivreurRating(order.id, selectedStars, ratingComment); + setRatingSubmitting(false); + if (res.success) { + setRatingDone({ rating: selectedStars, comment: ratingComment }); + setRatingModal(false); + } + }; + const getProductCount = (totalPrix: number): number => { return Math.max(1, Math.round(totalPrix / 25)); }; @@ -446,6 +470,22 @@ function OrderDetails() { )} + {order.status === "approved" && order.livreur_assign && ( + ratingDone ? ( +
+ + + Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""} + +
+ ) : ( + + ) + )} +
@@ -759,6 +799,45 @@ function OrderDetails() {
+ {/* Modal notation livreur */} + {ratingModal && ( +
setRatingModal(false)}> +
e.stopPropagation()}> +

Noter le livreur

+

{order.livreur_assign}

+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+