chore: build
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user