chore: build

This commit is contained in:
2026-06-16 21:17:54 +02:00
parent 440792a066
commit ec7e59550b
11 changed files with 868 additions and 1 deletions
+14
View File
@@ -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 {
+62
View File
@@ -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
}
+118
View File
@@ -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})
}
+5
View File
@@ -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)
// ============================================
+15
View File
@@ -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")
@@ -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<RouteInfo | null>(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() {
</Text>
)}
<TouchableOpacity
style={styles.ratingsBtn}
onPress={() => openRatings(item.username)}
activeOpacity={0.7}
>
<Ionicons name="star-outline" size={14} color="#f59e0b" />
<Text style={styles.ratingsBtnText}>Voir les avis</Text>
</TouchableOpacity>
{hasGPS && (
<TouchableOpacity
style={[
@@ -835,6 +955,78 @@ export default function DeliveryScreen() {
<Text style={styles.empty}>Aucun livreur</Text>
}
/>
{/* ── Modal avis livreur ── */}
<Modal
visible={ratingsModal !== null}
transparent
animationType="slide"
onRequestClose={() => setRatingsModal(null)}
>
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis — {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
+43
View File
@@ -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;
@@ -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
============================================ */
@@ -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<string>("");
const [showVideo, setShowVideo] = useState(false);
const [currentVideoUrl, setCurrentVideoUrl] = useState<string>("");
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() {
</div>
)}
{order.status === "approved" && order.livreur_assign && (
ratingDone ? (
<div className="rating-done-row">
<Star size={14} className="rating-star-filled" />
<span className="rating-done-text">
Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""}
</span>
</div>
) : (
<button className="rating-btn" onClick={() => setRatingModal(true)}>
<Star size={14} />
Noter le livreur
</button>
)
)}
<div className="info-row">
<div className="info-icon">
<Calendar size={18} />
@@ -759,6 +799,45 @@ function OrderDetails() {
</div>
</div>
{/* Modal notation livreur */}
{ratingModal && (
<div className="rating-modal-overlay" onClick={() => setRatingModal(false)}>
<div className="rating-modal-sheet" onClick={(e) => e.stopPropagation()}>
<h2 className="rating-modal-title">Noter le livreur</h2>
<p className="rating-modal-subtitle">{order.livreur_assign}</p>
<div className="rating-stars-row">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} className="rating-star-btn" onClick={() => setSelectedStars(star)}>
<Star
size={36}
className={star <= selectedStars ? "rating-star-filled" : "rating-star-empty"}
fill={star <= selectedStars ? "#f59e0b" : "none"}
/>
</button>
))}
</div>
<textarea
className="rating-comment-input"
placeholder="Commentaire (optionnel)"
value={ratingComment}
onChange={(e) => setRatingComment(e.target.value)}
maxLength={500}
rows={3}
/>
<button
className="rating-submit-btn"
onClick={handleSubmitRating}
disabled={selectedStars === 0 || ratingSubmitting}
>
{ratingSubmitting ? "Envoi..." : "Envoyer ma note"}
</button>
<button className="rating-cancel-btn" onClick={() => setRatingModal(false)}>
Annuler
</button>
</div>
</div>
)}
{/* Modal vidéo */}
{showVideo && currentVideoUrl && (
<div className="video-modal" onClick={handleCloseVideo}>
+24
View File
@@ -993,6 +993,30 @@ export const claimMyReward = async (poolKey: string, productId?: number): Promis
}
};
export const submitLivreurRating = async (
orderId: number,
rating: number,
comment: string,
): Promise<{ success: boolean; error?: string }> => {
try {
await apiClient.post(`${V1}/orders/${orderId}/rate`, { rating, comment });
return { success: true };
} catch (e: any) {
return { success: false, error: e?.response?.data?.error || "Erreur" };
}
};
export const getOrderRatingStatus = async (
orderId: number,
): Promise<{ rated: boolean; rating?: number; comment?: string }> => {
try {
const { data } = await apiClient.get(`${V1}/orders/${orderId}/rating`);
return data;
} catch {
return { rated: false };
}
};
export const calculateOrderTotal = (order: any): number => {
if (typeof order.total_prix === "number" && order.total_prix > 0) return order.total_prix;
if (typeof order.total === "number" && order.total > 0) return order.total;
@@ -1,5 +1,15 @@
import React, { useState, useEffect, useMemo } from "react";
import { View, Text, ScrollView, Image, StyleSheet } from "react-native";
import {
View,
Text,
ScrollView,
Image,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
Pressable,
} from "react-native";
import { useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
@@ -8,6 +18,8 @@ import {
getProductById,
formatOrderDate,
formatPrice,
submitLivreurRating,
getOrderRatingStatus,
} from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
@@ -54,6 +66,11 @@ export default function OrderDetailsScreen() {
const [products, setProducts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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);
useEffect(() => {
(async () => {
@@ -97,6 +114,10 @@ export default function OrderDetailsScreen() {
}),
);
setProducts(enriched);
if (cmd.status === "approved" && cmd.livreur_assign) {
const r = await getOrderRatingStatus(params.orderId);
if (r.rated) setRatingDone({ rating: r.rating!, comment: r.comment ?? "" });
}
} else {
setError("Commande introuvable");
}
@@ -108,6 +129,17 @@ export default function OrderDetailsScreen() {
})();
}, [params.orderId]);
const handleSubmitRating = async () => {
if (selectedStars === 0) return;
setRatingSubmitting(true);
const res = await submitLivreurRating(params.orderId, selectedStars, ratingComment);
setRatingSubmitting(false);
if (res.success) {
setRatingDone({ rating: selectedStars, comment: ratingComment });
setRatingModal(false);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
@@ -296,6 +328,89 @@ export default function OrderDetailsScreen() {
fontSize: fontSize.xxl,
fontWeight: fontWeight.bold,
},
ratingBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
backgroundColor: "#f59e0b",
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
marginTop: spacing.m,
},
ratingBtnText: {
color: "#fff",
fontSize: fontSize.sm,
fontWeight: fontWeight.bold,
},
ratingDoneRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginTop: spacing.m,
padding: spacing.s,
backgroundColor: "rgba(245,158,11,0.1)",
borderRadius: borderRadius.sm,
},
ratingDoneText: {
color: "#f59e0b",
fontSize: fontSize.sm,
flex: 1,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "flex-end",
},
modalSheet: {
backgroundColor: colors.bgCard,
borderTopLeftRadius: borderRadius.xl,
borderTopRightRadius: borderRadius.xl,
padding: spacing.l,
paddingBottom: spacing.xxxl,
},
modalTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
marginBottom: spacing.xs,
},
modalSubtitle: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginBottom: spacing.l,
},
starsRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.l,
},
commentInput: {
backgroundColor: colors.bgInput,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.borderLight,
color: colors.textPrimary,
padding: spacing.m,
fontSize: fontSize.sm,
minHeight: 80,
textAlignVertical: "top",
marginBottom: spacing.l,
},
submitBtn: {
backgroundColor: "#f59e0b",
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
alignItems: "center",
marginBottom: spacing.s,
},
submitBtnText: {
color: "#fff",
fontSize: fontSize.md,
fontWeight: fontWeight.bold,
},
cancelBtn: { alignItems: "center", paddingVertical: spacing.s },
cancelBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
}),
[colors],
);
@@ -454,6 +569,21 @@ export default function OrderDetailsScreen() {
</Text>
</View>
)}
{order.status === "approved" && order.livreur_assign && (
ratingDone ? (
<View style={styles.ratingDoneRow}>
<Ionicons name="star" size={14} color="#f59e0b" />
<Text style={styles.ratingDoneText}>
Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""}
</Text>
</View>
) : (
<TouchableOpacity style={styles.ratingBtn} onPress={() => setRatingModal(true)}>
<Ionicons name="star-outline" size={14} color="#fff" />
<Text style={styles.ratingBtnText}>Noter le livreur</Text>
</TouchableOpacity>
)
)}
{(order.client_prenom || order.first_name) && (
<View style={styles.infoRow}>
<Ionicons
@@ -540,5 +670,48 @@ export default function OrderDetailsScreen() {
</View>
</Card>
</ScrollView>
<Modal visible={ratingModal} transparent animationType="slide" onRequestClose={() => setRatingModal(false)}>
<Pressable style={styles.modalOverlay} onPress={() => setRatingModal(false)}>
<Pressable onPress={() => {}}>
<View style={styles.modalSheet}>
<Text style={styles.modalTitle}>Noter le livreur</Text>
<Text style={styles.modalSubtitle}>{order.livreur_assign}</Text>
<View style={styles.starsRow}>
{[1, 2, 3, 4, 5].map((star) => (
<TouchableOpacity key={star} onPress={() => setSelectedStars(star)}>
<Ionicons
name={star <= selectedStars ? "star" : "star-outline"}
size={36}
color="#f59e0b"
/>
</TouchableOpacity>
))}
</View>
<TextInput
style={styles.commentInput}
placeholder="Commentaire (optionnel)"
placeholderTextColor={colors.textMuted}
value={ratingComment}
onChangeText={setRatingComment}
multiline
maxLength={500}
/>
<TouchableOpacity
style={[styles.submitBtn, (selectedStars === 0 || ratingSubmitting) && { opacity: 0.5 }]}
onPress={handleSubmitRating}
disabled={selectedStars === 0 || ratingSubmitting}
>
<Text style={styles.submitBtnText}>
{ratingSubmitting ? "Envoi..." : "Envoyer ma note"}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.cancelBtn} onPress={() => setRatingModal(false)}>
<Text style={styles.cancelBtnText}>Annuler</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
);
}