// ============================================ // pages/SuiviLivraison.tsx - VERSION FINALE CORRIGÉE // ============================================ // ✅ Calcul total identique à Checkout (somme des prix) // ✅ FIX: Gestion correcte du code 409 Conflict // ✅ FIX: Suppression des useState non utilisés // ✅ AJOUT: Vérification continue de l'authentification import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { getMyOrders, getOrderTracking, getOrderETA, confirmReception, cancelCommand, isUserAuthenticated, getPublicSettings, } from "../../api/api"; import type { ETAResponse, TrackingResponse, OrderDetail, OrderItem, CancelCommandResponse, } from "../../api/api_types"; import Navbar from "../../components/Navbar"; import Toast from "../../components/Toast"; import "./SuiviLivraison.css"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faHourglassHalf, faTruck, faBox, faCheckCircle, faTimesCircle, faQuestionCircle, faMapMarkerAlt, faBiking, faClock, faShoppingCart, faMoneyBillWave, faCalendarAlt, faSync, faGift, faLightbulb, faCheck, faExclamationTriangle, faLeaf, faWind, faChevronUp, faChevronDown, } from "@fortawesome/free-solid-svg-icons"; interface OrderWithTracking extends OrderDetail { tracking?: TrackingResponse; eta?: ETAResponse; } interface ToastMessage { id: string; message: string; type: "success" | "error" | "warning" | "info"; } // ============================================ // ✅ HELPERS - Calcul total identique à Checkout // ============================================ /** * ✅ Calculer le total EXACTEMENT comme dans Checkout.tsx * Somme des prix individuels (pas de multiplication) */ const getTotalAmount = (order: OrderWithTracking): 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; } if (order.items && order.items.length > 0) { return order.items.reduce((sum, item) => { const itemPrice = item.prix || item.price || 0; return sum + itemPrice; }, 0); } return 0; }; const getClientInfo = (order: OrderWithTracking) => { const firstName = order.first_name || order.client_prenom || ""; const lastName = order.last_name || order.client_nom || ""; const phone = order.phone || order.client_telephone || ""; return { firstName, lastName, phone }; }; const getDeliveryAddress = (order: OrderWithTracking): string => { return order.delivery_address || order.adresse || "Non disponible"; }; const formatOrderItem = (item: OrderItem) => { return { name: String(item.produit || item.product_name || item.name_product || "Produit"), quantity: Number(item.quantite || item.quantity || 0), price: Number(item.prix || item.price || 0), }; }; const getStatusColor = (status: string): string => { switch (status?.toLowerCase()) { case "pending": return "linear-gradient(135deg, #ddd6fe 0%, #a78bfa 50%, #7c3aed 100%)"; case "assigned": return "linear-gradient(135deg, #fef3c7 0%, #fbbf24 50%, #f59e0b 100%)"; case "en_route": return "linear-gradient(135deg, #bfdbfe 0%, #60a5fa 50%, #3b82f6 100%)"; case "arrived": return "linear-gradient(135deg, #bbf7d0 0%, #4ade80 50%, #22c55e 100%)"; case "livre": return "linear-gradient(135deg, #f3e8ff 0%, #d8b4fe 50%, #a855f7 100%)"; case "approved": return "linear-gradient(135deg, #c7d2fe 0%, #a5b4fc 50%, #6366f1 100%)"; case "cancelled": return "linear-gradient(135deg, #fae8ff 0%, #f0abfc 50%, #c026d3 100%)"; default: return "linear-gradient(135deg, #e5e7eb 0%, #9ca3af 50%, #6b7280 100%)"; } }; const getStatusLabel = (status: string): string => { const statusMap: Record = { pending: "En attente d'assignation", assigned: "Livreur assigné", en_route: "En route vers vous", arrived: "Livreur arrivé", livre: "Livré - À confirmer", approved: "Livraison confirmée", cancelled: "Annulée", }; return statusMap[status?.toLowerCase()] || "Statut inconnu"; }; const getStatusIcon = (status: string): IconDefinition => { const iconMap: Record = { pending: faHourglassHalf, assigned: faBiking, en_route: faTruck, arrived: faMapMarkerAlt, livre: faBox, approved: faCheckCircle, cancelled: faTimesCircle, }; return iconMap[status?.toLowerCase()] || faQuestionCircle; }; /** * ✅ Calculer les points avec le TOTAL (pas item par item) */ const calculateOrderPoints = ( order: OrderWithTracking, poolNames: string[] = [], ): { points: number; category: string; categoryDisplay: string; categoryIcon: IconDefinition; categoryColor: string; } => { // Totaux indexés par pool (+ index spécial pour "gros&semi" exclu des points) const poolTotals: number[] = poolNames.map(() => 0); let excludedTotal = 0; if (order.items && order.items.length > 0) { order.items.forEach((item: OrderItem) => { const cat = String(item.category || "").toLowerCase(); const itemPrice = Number(item.prix || item.price || 0); if (cat.includes("gros") || cat.includes("semi")) { excludedTotal += itemPrice; return; } // Trouver le pool correspondant par nom (insensible à la casse) const poolIdx = poolNames.findIndex((name) => cat.includes(name.toLowerCase().replace(/[&\s]/g, "")), ); if (poolIdx >= 0) { poolTotals[poolIdx] += itemPrice; } else if (poolTotals.length > 0) { // Fallback : pool 0 si aucun match poolTotals[0] += itemPrice; } }); } // Gros&Semi uniquement → 0 points if (excludedTotal > 0 && poolTotals.every((t) => t === 0)) { return { points: 0, category: "excluded", categoryDisplay: "Gros&Semi", categoryIcon: faBox, categoryColor: "#9ca3af", }; } // Pool dominant = celui avec le plus grand total const dominantIdx = poolTotals.reduce( (best, val, i) => (val > poolTotals[best] ? i : best), 0, ); const dominantTotal = poolTotals[dominantIdx]; const categoryName = poolNames[dominantIdx] ?? ""; const poolIcons = [faWind, faLeaf, faGift, faBox]; const poolColors = ["#3b82f6", "#10b981", "#7c3aed", "#f59e0b"]; let points = 0; if (dominantTotal >= 30 && dominantTotal <= 100) points = 1; else if (dominantTotal >= 110 && dominantTotal <= 200) points = 2; else if (dominantTotal >= 210 && dominantTotal <= 300) points = 3; else if (dominantTotal >= 310 && dominantTotal <= 400) points = 5; else if (dominantTotal > 400) points = 10; return { points, category: categoryName.toLowerCase().replace(/\s/g, "_"), categoryDisplay: categoryName, categoryIcon: poolIcons[dominantIdx] ?? faGift, categoryColor: poolColors[dominantIdx] ?? "#7c3aed", }; }; // ============================================ // TIMELINE STEPS // ============================================ const TIMELINE_STEPS = [ { key: "pending", label: "En attente", icon: faHourglassHalf }, { key: "assigned", label: "Assigné", icon: faBiking }, { key: "en_route", label: "En route", icon: faTruck }, { key: "arrived", label: "Arrivé", icon: faMapMarkerAlt }, { key: "livre", label: "Livré", icon: faBox }, { key: "approved", label: "Confirmé", icon: faCheckCircle }, ]; const STATUS_ORDER: Record = { pending: 0, assigned: 1, en_route: 2, arrived: 3, livre: 4, approved: 5, }; // ============================================ // COMPONENT PRINCIPAL // ============================================ function SuiviLivraison() { const navigate = useNavigate(); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [expandedOrder, setExpandedOrder] = useState(null); const [confirming, setConfirming] = useState(null); const [toasts, setToasts] = useState([]); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [orderToConfirm, setOrderToConfirm] = useState(null); const [selectedOrderPoints, setSelectedOrderPoints] = useState(0); const [selectedOrderCategoryDisplay, setSelectedOrderCategoryDisplay] = useState(""); const [cancellingOrder, setCancellingOrder] = useState(null); const [showCancelDialog, setShowCancelDialog] = useState(false); const [orderToCancel, setOrderToCancel] = useState(null); const [cancelReason, setCancelReason] = useState(""); const [showPenaltyWarning, setShowPenaltyWarning] = useState(false); const [penaltyWarningData, setPenaltyWarningData] = useState(null); const [poolNames, setPoolNames] = useState([]); useEffect(() => { getPublicSettings().then((s) => setPoolNames(s.pool_names ?? [])); }, []); // ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION useEffect(() => { const checkAuth = () => { if (!isUserAuthenticated()) { console.log( "❌ [SuiviLivraison] Utilisateur non authentifié, redirection vers /login/client", ); navigate("/login/client", { replace: true }); } }; checkAuth(); }, [navigate]); // ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes) useEffect(() => { const authInterval = setInterval(() => { if (!isUserAuthenticated()) { console.log( "❌ [SuiviLivraison] Session expirée, redirection vers /login/client", ); navigate("/login/client", { replace: true }); } }, 5000); return () => clearInterval(authInterval); }, [navigate]); useEffect(() => { loadOrders(); const interval = setInterval(loadOrders, 10000); return () => clearInterval(interval); }, []); // eslint-disable-line react-hooks/exhaustive-deps const loadOrders = async () => { if (!isUserAuthenticated()) { navigate("/login/client", { replace: true }); return; } try { const response = await getMyOrders(); if (response.success) { const ordersWithTracking = await Promise.all( (response.commands || []).map(async (order: OrderDetail) => { const normalizedOrder = { ...order, total: getTotalAmount(order), }; let tracking; let eta; try { tracking = await getOrderTracking(order.id); } catch { tracking = undefined; } try { eta = await getOrderETA(order.id); } catch { eta = undefined; } return { ...normalizedOrder, tracking, eta, }; }), ); setOrders(ordersWithTracking); setError(""); } } catch (err: unknown) { console.error("Erreur loadOrders:", err); const msg = err instanceof Error ? err.message : "Erreur lors du chargement"; setError(msg); showToast(msg, "error"); } finally { setLoading(false); } }; const showToast = ( message: string, type: "success" | "error" | "warning" | "info", ) => { const id = Date.now().toString(); setToasts((prev) => [...prev, { id, message, type }]); }; const removeToast = (id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); }; const openConfirmDialog = (orderId: number) => { // ✅ Vérifier l'auth avant d'ouvrir le dialog if (!isUserAuthenticated()) { console.log("❌ [openConfirmDialog] Non authentifié"); navigate("/login/client", { replace: true }); return; } const order = orders.find((o) => o.id === orderId); if (order) { const { points, categoryDisplay } = calculateOrderPoints(order, poolNames); setSelectedOrderPoints(points); setSelectedOrderCategoryDisplay(categoryDisplay); } else { setSelectedOrderPoints(0); setSelectedOrderCategoryDisplay(""); } setOrderToConfirm(orderId); setShowConfirmDialog(true); }; const closeConfirmDialog = () => { setShowConfirmDialog(false); setOrderToConfirm(null); setSelectedOrderPoints(0); setSelectedOrderCategoryDisplay(""); }; const handleConfirmReception = async () => { if (!orderToConfirm || confirming) return; // ✅ Vérifier l'auth avant de confirmer if (!isUserAuthenticated()) { console.log("❌ [handleConfirmReception] Non authentifié"); navigate("/login/client", { replace: true }); return; } try { setConfirming(orderToConfirm); closeConfirmDialog(); showToast("Confirmation en cours...", "info"); const response = await confirmReception(orderToConfirm); if (response.success) { const pointsEarned = response.points_earned || selectedOrderPoints; const apiCategory = response.category || response.data?.category || ""; const displayCategory = apiCategory && apiCategory !== "total" ? apiCategory : selectedOrderCategoryDisplay; showToast( `Commande confirmée! +${pointsEarned} point${pointsEarned > 1 ? "s" : ""} ${displayCategory}`, "success", ); setConfirming(null); loadOrders(); } else { setError(response.message || "Erreur lors de la confirmation"); showToast( response.message || "Erreur lors de la confirmation", "error", ); setConfirming(null); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Erreur serveur"; setError(msg); showToast(msg, "error"); setConfirming(null); } }; const openCancelDialog = (orderId: number) => { // ✅ Vérifier l'auth avant d'ouvrir le dialog if (!isUserAuthenticated()) { console.log("❌ [openCancelDialog] Non authentifié"); navigate("/login/client", { replace: true }); return; } setOrderToCancel(orderId); setCancelReason(""); setShowPenaltyWarning(false); setPenaltyWarningData(null); setShowCancelDialog(true); }; const closeCancelDialog = () => { setShowCancelDialog(false); setOrderToCancel(null); setCancelReason(""); setShowPenaltyWarning(false); setPenaltyWarningData(null); }; const handleCancelOrder = async (force: boolean = false) => { if (!orderToCancel) return; // ✅ Vérifier l'auth avant d'annuler if (!isUserAuthenticated()) { console.log("❌ [handleCancelOrder] Non authentifié"); navigate("/login/client", { replace: true }); return; } try { setCancellingOrder(orderToCancel); if (!force) { showToast("Vérification en cours...", "info"); } const response = await cancelCommand( orderToCancel, cancelReason, force, ); if (response.warning && response.penalty_warning && !force) { console.log( "⚠️ [CANCEL] Avertissement reçu:", response.penalty_warning, ); setPenaltyWarningData(response); setShowPenaltyWarning(true); setCancellingOrder(null); return; } if (response.success) { let message = "Commande annulée avec succès"; if (response.penalty) { message += ` (Pénalité: ${response.penalty.points} points)`; showToast(message, "warning"); } else if (response.info) { showToast(message + " - " + response.info, "success"); } else { showToast(message, "success"); } closeCancelDialog(); loadOrders(); } else { showToast( response.message || "Erreur lors de l'annulation", "error", ); } } catch (error: unknown) { console.error("❌ [CANCEL] Erreur:", error); showToast(error instanceof Error ? error.message : "Erreur lors de l'annulation", "error"); } finally { setCancellingOrder(null); } }; const confirmCancelWithPenalty = () => { setShowPenaltyWarning(false); handleCancelOrder(true); }; if (loading && orders.length === 0) { return ( <>

Chargement de vos commandes...

); } return ( <>

Suivi de vos commandes

{error && (
{error}
)} {orders.length === 0 ? (

Aucune commande trouvée

Vous n'avez pas encore passé de commande.

) : (
{orders.map((order) => { const statusLow = order.status?.toLowerCase() ?? ""; const currentIdx = STATUS_ORDER[statusLow] ?? -1; const isCancelled = statusLow === "cancelled"; const showEta = order.eta?.eta_available && order.eta.eta_minutes > 0 && statusLow === "en_route"; // Heure d'arrivée estimée : depuis le backend ou calculée côté client const computedArrival = (() => { if (statusLow !== "en_route") return null; if (order.eta?.estimated_arrival) return order.eta.estimated_arrival; if (order.eta?.eta_minutes && order.eta.eta_minutes > 0) { return new Date(Date.now() + order.eta.eta_minutes * 60000) .toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); } return null; })(); const showArrivedEta = statusLow === "arrived"; return (
{/* Colored strip */}
{/* Clickable header */}
setExpandedOrder( expandedOrder === order.id ? null : order.id, ) } role="button" tabIndex={0} >
Commande #{order.client_order_number}
{" "} {getStatusLabel( order.status, )}
{(showEta || (statusLow === "en_route" && computedArrival)) && (
{` Vers ${computedArrival}`}
)} {showArrivedEta && (
{" ~5 min"}
)}
{new Date( order.created_at, ).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", })}
{/* Expanded details */} {expandedOrder === order.id && (
{/* Timeline */} {!isCancelled && (
{TIMELINE_STEPS.map( (step, idx) => { const isDone = currentIdx > idx; const isActive = currentIdx === idx; return (
{ step.label }
); }, )}
)} {/* ETA card - arrived */} {showArrivedEta && (

Livreur sur place

~5 min

Préparez-vous à réceptionner votre commande

)} {/* ETA card - en_route */} {(showEta || (statusLow === "en_route" && computedArrival)) && (

Heure d'arrivée estimée

{computedArrival && (

{computedArrival}

)} {order.eta?.eta_minutes && order.eta.eta_minutes > 0 && (

~{order.eta.eta_minutes} min restantes

)} {order.eta?.livreur_distance != null && (

Distance :{" "} {typeof order.eta.livreur_distance === "number" ? order.eta.livreur_distance.toFixed(1) : order.eta.livreur_distance}{" "} km

)}
)} {/* Detail sections */}

{" "} Adresse de livraison

{getDeliveryAddress( order, )}

{(() => { const info = getClientInfo( order, ); if ( info.firstName || info.lastName ) { return (

{ info.firstName }{" "} { info.lastName } {info.phone && ` • ${info.phone}`}

); } return null; })()}
{order.livreur_assign && (

{" "} Livreur assigné

{ order.livreur_assign }

)} {order.items && order.items.length > 0 && (

{" "} Produits

{order.items.map( ( item: OrderItem, idx: number, ) => { const formatted = formatOrderItem( item, ); return (
{ formatted.name }{" "} ( { formatted.quantity } g) {formatted.price.toFixed( 2, )}{" "} €
); }, )}
)}

{" "} Montant total

{(order.referral_used ?? 0) > 0 && (

Brut : {(order.total_prix ?? 0).toFixed(2)} €

)}

{Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).toFixed(2)} €

{(order.referral_used ?? 0) > 0 && (

— dont {(order.referral_used!).toFixed(2)} € parrainage déduit

)}

{" "} Date de commande

{new Date( order.created_at, ).toLocaleDateString( "fr-FR", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", }, )}

{/* Actions */}
{statusLow === "livre" ? (

{" "} Votre colis a été livré

Confirmez la réception pour valider la livraison et gagner des points

) : statusLow === "approved" ? (
{" "} Commande validée et confirmée
) : isCancelled ? (
{" "} Cette commande a été annulée
) : (
)}
)}
); })}
)}
{/* Dialog de confirmation */} {showConfirmDialog && (
e.stopPropagation()} >

{" "} Confirmer la réception

Confirmez-vous avoir bien reçu votre commande ?

{selectedOrderPoints > 0 ? `+${selectedOrderPoints} point${selectedOrderPoints > 1 ? "s" : ""} de fidélité` : "Commande éligible aux points de fidélité"}
{selectedOrderPoints > 0 && (

Accumulez des points pour obtenir des récompenses !

)}
)} {/* Dialog d'annulation */} {showCancelDialog && (
e.stopPropagation()} >

{showPenaltyWarning ? " Confirmation requise" : " Annuler la commande"}

{showPenaltyWarning && penaltyWarningData ? (

{penaltyWarningData.message}

{penaltyWarningData.details && (

Livreur assigné: {" "} { penaltyWarningData.details .livreur }

Statut:{" "} {getStatusLabel( penaltyWarningData.details .status || "", )}

{penaltyWarningData.details .position_in_queue && (

Position dans la queue: {" "} { penaltyWarningData .details .position_in_queue }

)}
)} {penaltyWarningData.penalty_warning && (
{penaltyWarningData.penalty_warning .will_apply ? ( <>

⚠️ Pénalité:{" "} { penaltyWarningData .penalty_warning .penalty_amount }{" "} points

{ penaltyWarningData .penalty_warning .message }

Barème des pénalités:

  • 1ère annulation:{" "} { penaltyWarningData .penalty_warning .scale[ "1st_cancel" ] }
  • 2ème annulation:{" "} { penaltyWarningData .penalty_warning .scale[ "2nd_cancel" ] }
  • 3ème annulation:{" "} { penaltyWarningData .penalty_warning .scale[ "3rd_cancel" ] }
  • 4ème+ annulation:{" "} { penaltyWarningData .penalty_warning .scale[ "4th+_cancel" ] }
) : (
{ penaltyWarningData .penalty_warning .message }
)}
)}

{penaltyWarningData.penalty_warning ?.will_apply ? "Voulez-vous vraiment annuler cette commande et accepter la pénalité ?" : "Voulez-vous vraiment annuler cette commande ?"}

) : (

Êtes-vous sûr de vouloir annuler cette commande ?