1430 lines
71 KiB
TypeScript
1430 lines
71 KiB
TypeScript
// ============================================
|
||
// 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 => {
|
||
// 1. Priorité: champ total stocké en DB
|
||
if (typeof order.total === "number" && order.total > 0) {
|
||
return order.total;
|
||
}
|
||
|
||
// 2. Fallback: total_prix
|
||
if (typeof order.total_prix === "number" && order.total_prix > 0) {
|
||
return order.total_prix;
|
||
}
|
||
|
||
// 3. Calcul depuis items (comme dans Checkout: somme des prix)
|
||
if (order.items && order.items.length > 0) {
|
||
const calculatedTotal = order.items.reduce((sum, item) => {
|
||
const itemPrice = item.prix || item.price || 0;
|
||
return sum + itemPrice; // ✅ Somme simple (pas de × quantity)
|
||
}, 0);
|
||
|
||
console.log(
|
||
`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}€`,
|
||
);
|
||
return calculatedTotal;
|
||
}
|
||
|
||
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<string, string> = {
|
||
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<string, IconDefinition> = {
|
||
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<string, number> = {
|
||
pending: 0,
|
||
assigned: 1,
|
||
en_route: 2,
|
||
arrived: 3,
|
||
livre: 4,
|
||
approved: 5,
|
||
};
|
||
|
||
// ============================================
|
||
// COMPONENT PRINCIPAL
|
||
// ============================================
|
||
|
||
function SuiviLivraison() {
|
||
const navigate = useNavigate();
|
||
const [orders, setOrders] = useState<OrderWithTracking[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [expandedOrder, setExpandedOrder] = useState<number | null>(null);
|
||
const [confirming, setConfirming] = useState<number | null>(null);
|
||
|
||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||
|
||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||
const [orderToConfirm, setOrderToConfirm] = useState<number | null>(null);
|
||
const [selectedOrderPoints, setSelectedOrderPoints] = useState<number>(0);
|
||
const [selectedOrderCategoryDisplay, setSelectedOrderCategoryDisplay] =
|
||
useState<string>("");
|
||
|
||
const [cancellingOrder, setCancellingOrder] = useState<number | null>(null);
|
||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||
const [orderToCancel, setOrderToCancel] = useState<number | null>(null);
|
||
const [cancelReason, setCancelReason] = useState("");
|
||
const [showPenaltyWarning, setShowPenaltyWarning] = useState(false);
|
||
const [penaltyWarningData, setPenaltyWarningData] =
|
||
useState<CancelCommandResponse | null>(null);
|
||
const [poolNames, setPoolNames] = useState<string[]>([]);
|
||
|
||
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 () => {
|
||
// ✅ Vérifier l'auth avant de charger les commandes
|
||
if (!isUserAuthenticated()) {
|
||
console.log("❌ [loadOrders] Non authentifié");
|
||
navigate("/login/client", { replace: true });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
const response = await getMyOrders();
|
||
|
||
if (response.success && response.commands) {
|
||
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 {
|
||
console.warn(
|
||
`Tracking non disponible pour commande ${order.id}`,
|
||
);
|
||
tracking = undefined;
|
||
}
|
||
|
||
try {
|
||
eta = await getOrderETA(order.id);
|
||
} catch {
|
||
console.warn(
|
||
`ETA non disponible pour commande ${order.id}`,
|
||
);
|
||
eta = undefined;
|
||
}
|
||
|
||
return {
|
||
...normalizedOrder,
|
||
tracking,
|
||
eta,
|
||
};
|
||
}),
|
||
);
|
||
|
||
setOrders(ordersWithTracking);
|
||
setError("");
|
||
} else {
|
||
setError("Impossible de charger les commandes");
|
||
showToast("Impossible de charger les commandes", "error");
|
||
}
|
||
} 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 (
|
||
<>
|
||
<Navbar />
|
||
<div className="suivi-container">
|
||
<div className="loading-state">
|
||
<div className="spinner"></div>
|
||
<p>Chargement de vos commandes...</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Navbar />
|
||
<div className="suivi-container">
|
||
<div className="suivi-header">
|
||
<h1>Suivi de vos commandes</h1>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="error-banner">
|
||
<FontAwesomeIcon icon={faExclamationTriangle} /> {error}
|
||
<button onClick={() => setError("")}>✕</button>
|
||
</div>
|
||
)}
|
||
|
||
{orders.length === 0 ? (
|
||
<div className="empty-state">
|
||
<div className="empty-state-icon">
|
||
<FontAwesomeIcon icon={faShoppingCart} />
|
||
</div>
|
||
<h2>Aucune commande trouvée</h2>
|
||
<p>Vous n'avez pas encore passé de commande.</p>
|
||
<button
|
||
className="action-button"
|
||
onClick={() => navigate("/user/nos-produits")}
|
||
>
|
||
Découvrir nos produits
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="orders-list">
|
||
{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 (
|
||
<div key={order.id} className="order-card">
|
||
{/* Colored strip */}
|
||
<div
|
||
className="order-status-strip"
|
||
style={{
|
||
backgroundImage: getStatusColor(
|
||
order.status,
|
||
),
|
||
}}
|
||
/>
|
||
|
||
{/* Clickable header */}
|
||
<div
|
||
className="order-header"
|
||
onClick={() =>
|
||
setExpandedOrder(
|
||
expandedOrder === order.id
|
||
? null
|
||
: order.id,
|
||
)
|
||
}
|
||
role="button"
|
||
tabIndex={0}
|
||
>
|
||
<div className="order-header-left">
|
||
<div className="order-id-row">
|
||
<span className="order-id">
|
||
Commande #{order.client_order_number}
|
||
</span>
|
||
<div
|
||
className="status-badge"
|
||
style={{
|
||
backgroundImage:
|
||
getStatusColor(
|
||
order.status,
|
||
),
|
||
}}
|
||
>
|
||
<FontAwesomeIcon
|
||
icon={getStatusIcon(
|
||
order.status,
|
||
)}
|
||
/>{" "}
|
||
{getStatusLabel(
|
||
order.status,
|
||
)}
|
||
</div>
|
||
{(showEta || (statusLow === "en_route" && computedArrival)) && (
|
||
<div className="eta-badge eta-badge--enRoute">
|
||
<FontAwesomeIcon icon={faClock} />
|
||
{` Vers ${computedArrival}`}
|
||
</div>
|
||
)}
|
||
{showArrivedEta && (
|
||
<div className="eta-badge eta-badge--arrived">
|
||
<FontAwesomeIcon icon={faClock} />
|
||
{" ~5 min"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<span className="order-date">
|
||
{new Date(
|
||
order.created_at,
|
||
).toLocaleDateString("fr-FR", {
|
||
day: "numeric",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="order-header-right">
|
||
<span className="expand-btn">
|
||
<FontAwesomeIcon
|
||
icon={
|
||
expandedOrder ===
|
||
order.id
|
||
? faChevronUp
|
||
: faChevronDown
|
||
}
|
||
/>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded details */}
|
||
{expandedOrder === order.id && (
|
||
<div className="order-details">
|
||
{/* Timeline */}
|
||
{!isCancelled && (
|
||
<div className="status-timeline">
|
||
{TIMELINE_STEPS.map(
|
||
(step, idx) => {
|
||
const isDone =
|
||
currentIdx >
|
||
idx;
|
||
const isActive =
|
||
currentIdx ===
|
||
idx;
|
||
return (
|
||
<div
|
||
key={
|
||
step.key
|
||
}
|
||
className={`timeline-step${isDone ? " done" : ""}${isActive ? " active" : ""}`}
|
||
>
|
||
<div className="timeline-dot">
|
||
<FontAwesomeIcon
|
||
icon={
|
||
isDone ||
|
||
isActive
|
||
? step.icon
|
||
: faQuestionCircle
|
||
}
|
||
/>
|
||
</div>
|
||
<span className="timeline-label">
|
||
{
|
||
step.label
|
||
}
|
||
</span>
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ETA card - arrived */}
|
||
{showArrivedEta && (
|
||
<div className="eta-card eta-card--arrived">
|
||
<div className="eta-card-icon">
|
||
<FontAwesomeIcon icon={faMapMarkerAlt} />
|
||
</div>
|
||
<div className="eta-card-body">
|
||
<p className="eta-card-title">Livreur sur place</p>
|
||
<p className="eta-card-value">~5 min</p>
|
||
<p className="eta-card-sub">Préparez-vous à réceptionner votre commande</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ETA card - en_route */}
|
||
{(showEta || (statusLow === "en_route" && computedArrival)) && (
|
||
<div
|
||
className="eta-card eta-card--enRoute"
|
||
>
|
||
<div className="eta-card-icon">
|
||
<FontAwesomeIcon icon={faClock} />
|
||
</div>
|
||
<div className="eta-card-body">
|
||
<p className="eta-card-title">Heure d'arrivée estimée</p>
|
||
{computedArrival && (
|
||
<p className="eta-card-value">{computedArrival}</p>
|
||
)}
|
||
{order.eta?.eta_minutes && order.eta.eta_minutes > 0 && (
|
||
<p className="eta-card-sub">
|
||
~{order.eta.eta_minutes} min restantes
|
||
</p>
|
||
)}
|
||
{order.eta?.livreur_distance != null && (
|
||
<p className="eta-card-sub">
|
||
Distance :{" "}
|
||
{typeof order.eta.livreur_distance === "number"
|
||
? order.eta.livreur_distance.toFixed(1)
|
||
: order.eta.livreur_distance}{" "}
|
||
km
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Detail sections */}
|
||
<div className="details-grid">
|
||
<div className="detail-section">
|
||
<h4>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faMapMarkerAlt
|
||
}
|
||
/>{" "}
|
||
Adresse de livraison
|
||
</h4>
|
||
<p className="address">
|
||
{getDeliveryAddress(
|
||
order,
|
||
)}
|
||
</p>
|
||
{(() => {
|
||
const info =
|
||
getClientInfo(
|
||
order,
|
||
);
|
||
if (
|
||
info.firstName ||
|
||
info.lastName
|
||
) {
|
||
return (
|
||
<p className="contact">
|
||
{
|
||
info.firstName
|
||
}{" "}
|
||
{
|
||
info.lastName
|
||
}
|
||
{info.phone &&
|
||
` • ${info.phone}`}
|
||
</p>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
</div>
|
||
|
||
{order.livreur_assign && (
|
||
<div className="detail-section">
|
||
<h4>
|
||
<FontAwesomeIcon
|
||
icon={faBiking}
|
||
/>{" "}
|
||
Livreur assigné
|
||
</h4>
|
||
<p>
|
||
{
|
||
order.livreur_assign
|
||
}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{order.items &&
|
||
order.items.length > 0 && (
|
||
<div className="detail-section">
|
||
<h4>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faShoppingCart
|
||
}
|
||
/>{" "}
|
||
Produits
|
||
</h4>
|
||
<div className="items-list">
|
||
{order.items.map(
|
||
(
|
||
item: OrderItem,
|
||
idx: number,
|
||
) => {
|
||
const formatted =
|
||
formatOrderItem(
|
||
item,
|
||
);
|
||
return (
|
||
<div
|
||
key={
|
||
idx
|
||
}
|
||
className="item"
|
||
>
|
||
<span className="item-name">
|
||
{
|
||
formatted.name
|
||
}{" "}
|
||
(
|
||
{
|
||
formatted.quantity
|
||
}
|
||
g)
|
||
</span>
|
||
<span className="price">
|
||
{formatted.price.toFixed(
|
||
2,
|
||
)}{" "}
|
||
€
|
||
</span>
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="detail-section">
|
||
<h4>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faMoneyBillWave
|
||
}
|
||
/>{" "}
|
||
Montant total
|
||
</h4>
|
||
<p className="total-amount">
|
||
<strong>
|
||
{getTotalAmount(
|
||
order,
|
||
).toFixed(2)}{" "}
|
||
€
|
||
</strong>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="detail-section">
|
||
<h4>
|
||
<FontAwesomeIcon
|
||
icon={faCalendarAlt}
|
||
/>{" "}
|
||
Date de commande
|
||
</h4>
|
||
<p>
|
||
{new Date(
|
||
order.created_at,
|
||
).toLocaleDateString(
|
||
"fr-FR",
|
||
{
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
},
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="order-actions">
|
||
{statusLow === "livre" ? (
|
||
<div className="confirmation-section">
|
||
<div className="delivery-notice">
|
||
<p className="notice-title">
|
||
<FontAwesomeIcon
|
||
icon={faBox}
|
||
/>{" "}
|
||
Votre colis a
|
||
été livré
|
||
</p>
|
||
<p className="notice-subtitle">
|
||
Confirmez la
|
||
réception pour
|
||
valider la
|
||
livraison et
|
||
gagner des
|
||
points
|
||
</p>
|
||
</div>
|
||
<button
|
||
className="btn-confirm-delivery"
|
||
onClick={() =>
|
||
openConfirmDialog(
|
||
order.id,
|
||
)
|
||
}
|
||
disabled={
|
||
confirming ===
|
||
order.id
|
||
}
|
||
>
|
||
{confirming ===
|
||
order.id ? (
|
||
<>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faClock
|
||
}
|
||
spin
|
||
/>{" "}
|
||
Confirmation...
|
||
</>
|
||
) : (
|
||
<>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faCheck
|
||
}
|
||
/>{" "}
|
||
J'ai bien
|
||
reçu ma
|
||
commande
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
) : statusLow === "approved" ? (
|
||
<div className="status-message success">
|
||
<FontAwesomeIcon
|
||
icon={faCheckCircle}
|
||
/>{" "}
|
||
Commande validée et
|
||
confirmée
|
||
</div>
|
||
) : isCancelled ? (
|
||
<div className="status-message cancelled">
|
||
<FontAwesomeIcon
|
||
icon={faTimesCircle}
|
||
/>{" "}
|
||
Cette commande a été
|
||
annulée
|
||
</div>
|
||
) : (
|
||
<div className="action-buttons">
|
||
<button
|
||
className="btn-cancel-order"
|
||
onClick={() =>
|
||
openCancelDialog(
|
||
order.id,
|
||
)
|
||
}
|
||
disabled={
|
||
cancellingOrder ===
|
||
order.id
|
||
}
|
||
>
|
||
{cancellingOrder ===
|
||
order.id ? (
|
||
<>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faClock
|
||
}
|
||
spin
|
||
/>{" "}
|
||
Annulation...
|
||
</>
|
||
) : (
|
||
<>
|
||
<FontAwesomeIcon
|
||
icon={
|
||
faTimesCircle
|
||
}
|
||
/>{" "}
|
||
Annuler la
|
||
commande
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
className="btn-secondary"
|
||
onClick={() => {
|
||
loadOrders();
|
||
showToast(
|
||
"Actualisation en cours...",
|
||
"info",
|
||
);
|
||
}}
|
||
>
|
||
<FontAwesomeIcon
|
||
icon={faSync}
|
||
/>{" "}
|
||
Actualiser
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dialog de confirmation */}
|
||
{showConfirmDialog && (
|
||
<div
|
||
className="confirm-dialog-overlay"
|
||
onClick={closeConfirmDialog}
|
||
>
|
||
<div
|
||
className="confirm-dialog"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="confirm-dialog-header">
|
||
<h3>
|
||
<FontAwesomeIcon icon={faCheckCircle} />{" "}
|
||
Confirmer la réception
|
||
</h3>
|
||
</div>
|
||
<div className="confirm-dialog-body">
|
||
<p>
|
||
Confirmez-vous avoir bien reçu votre commande ?
|
||
</p>
|
||
|
||
<div className="confirm-dialog-reward">
|
||
<FontAwesomeIcon icon={faGift} />
|
||
<strong>
|
||
{selectedOrderPoints > 0
|
||
? `+${selectedOrderPoints} point${selectedOrderPoints > 1 ? "s" : ""} de fidélité`
|
||
: "Commande éligible aux points de fidélité"}
|
||
</strong>
|
||
</div>
|
||
|
||
{selectedOrderPoints > 0 && (
|
||
<div className="points-info">
|
||
<p>
|
||
Accumulez des points pour obtenir des
|
||
récompenses !
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="confirm-dialog-actions">
|
||
<button
|
||
className="btn-secondary"
|
||
onClick={closeConfirmDialog}
|
||
>
|
||
Retour
|
||
</button>
|
||
<button
|
||
className="btn-confirm"
|
||
onClick={handleConfirmReception}
|
||
>
|
||
<FontAwesomeIcon icon={faCheck} /> Confirmer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Dialog d'annulation */}
|
||
{showCancelDialog && (
|
||
<div
|
||
className="confirm-dialog-overlay"
|
||
onClick={closeCancelDialog}
|
||
>
|
||
<div
|
||
className="confirm-dialog cancel-dialog"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="confirm-dialog-header">
|
||
<h3>
|
||
<FontAwesomeIcon icon={faTimesCircle} />
|
||
{showPenaltyWarning
|
||
? " Confirmation requise"
|
||
: " Annuler la commande"}
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="confirm-dialog-body">
|
||
{showPenaltyWarning && penaltyWarningData ? (
|
||
<div className="penalty-warning">
|
||
<div className="warning-icon">
|
||
<FontAwesomeIcon
|
||
icon={faExclamationTriangle}
|
||
/>
|
||
</div>
|
||
<p className="warning-title">
|
||
{penaltyWarningData.message}
|
||
</p>
|
||
|
||
{penaltyWarningData.details && (
|
||
<div className="warning-details">
|
||
<p>
|
||
<strong>
|
||
Livreur assigné:
|
||
</strong>{" "}
|
||
{
|
||
penaltyWarningData.details
|
||
.livreur
|
||
}
|
||
</p>
|
||
<p>
|
||
<strong>Statut:</strong>{" "}
|
||
{getStatusLabel(
|
||
penaltyWarningData.details
|
||
.status || "",
|
||
)}
|
||
</p>
|
||
{penaltyWarningData.details
|
||
.position_in_queue && (
|
||
<p>
|
||
<strong>
|
||
Position dans la queue:
|
||
</strong>{" "}
|
||
{
|
||
penaltyWarningData
|
||
.details
|
||
.position_in_queue
|
||
}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{penaltyWarningData.penalty_warning && (
|
||
<div className="penalty-info">
|
||
{penaltyWarningData.penalty_warning
|
||
.will_apply ? (
|
||
<>
|
||
<p className="penalty-amount">
|
||
<strong>
|
||
⚠️ Pénalité:{" "}
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.penalty_amount
|
||
}{" "}
|
||
points
|
||
</strong>
|
||
</p>
|
||
<p className="penalty-message">
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.message
|
||
}
|
||
</p>
|
||
<div className="penalty-scale">
|
||
<p>
|
||
<strong>
|
||
Barème des
|
||
pénalités:
|
||
</strong>
|
||
</p>
|
||
<ul>
|
||
<li>
|
||
1ère annulation:{" "}
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.scale[
|
||
"1st_cancel"
|
||
]
|
||
}
|
||
</li>
|
||
<li>
|
||
2ème annulation:{" "}
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.scale[
|
||
"2nd_cancel"
|
||
]
|
||
}
|
||
</li>
|
||
<li>
|
||
3ème annulation:{" "}
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.scale[
|
||
"3rd_cancel"
|
||
]
|
||
}
|
||
</li>
|
||
<li>
|
||
4ème+
|
||
annulation:{" "}
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.scale[
|
||
"4th+_cancel"
|
||
]
|
||
}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="info-message">
|
||
<FontAwesomeIcon
|
||
icon={faLightbulb}
|
||
/>
|
||
<span>
|
||
{
|
||
penaltyWarningData
|
||
.penalty_warning
|
||
.message
|
||
}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<p className="warning-question">
|
||
{penaltyWarningData.penalty_warning
|
||
?.will_apply
|
||
? "Voulez-vous vraiment annuler cette commande et accepter la pénalité ?"
|
||
: "Voulez-vous vraiment annuler cette commande ?"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="cancel-form">
|
||
<p>
|
||
Êtes-vous sûr de vouloir annuler cette
|
||
commande ?
|
||
</p>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="cancel-reason">
|
||
Raison de l'annulation (optionnelle)
|
||
</label>
|
||
<textarea
|
||
id="cancel-reason"
|
||
value={cancelReason}
|
||
onChange={(e) =>
|
||
setCancelReason(e.target.value)
|
||
}
|
||
placeholder="Expliquez pourquoi vous annulez cette commande..."
|
||
rows={4}
|
||
maxLength={500}
|
||
/>
|
||
<small>
|
||
{cancelReason.length}/500 caractères
|
||
</small>
|
||
</div>
|
||
|
||
<div className="info-message">
|
||
<FontAwesomeIcon icon={faLightbulb} />
|
||
<span>
|
||
Si aucun livreur n'est assigné,
|
||
l'annulation est gratuite. Sinon,
|
||
une confirmation sera demandée.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="confirm-dialog-actions">
|
||
<button
|
||
className="btn-secondary"
|
||
onClick={closeCancelDialog}
|
||
disabled={cancellingOrder !== null}
|
||
>
|
||
Retour
|
||
</button>
|
||
|
||
<button
|
||
className="btn-danger"
|
||
onClick={() =>
|
||
showPenaltyWarning
|
||
? confirmCancelWithPenalty()
|
||
: handleCancelOrder(false)
|
||
}
|
||
disabled={cancellingOrder !== null}
|
||
>
|
||
{showPenaltyWarning ? (
|
||
<>
|
||
<FontAwesomeIcon icon={faCheck} />{" "}
|
||
Confirmer l'annulation
|
||
</>
|
||
) : (
|
||
<>
|
||
<FontAwesomeIcon icon={faTimesCircle} />{" "}
|
||
Annuler la commande
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toasts */}
|
||
{toasts.map((toast) => (
|
||
<Toast
|
||
key={toast.id}
|
||
message={toast.message}
|
||
type={toast.type}
|
||
duration={3000}
|
||
onClose={() => removeToast(toast.id)}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default SuiviLivraison;
|