chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
// ============================================
|
||||
// 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
|
||||
} from '../../api/api';
|
||||
import type {
|
||||
ETAResponse,
|
||||
TrackingResponse,
|
||||
OrderDetail,
|
||||
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 {
|
||||
faHourglassHalf,
|
||||
faTruck,
|
||||
faBox,
|
||||
faCheckCircle,
|
||||
faTimesCircle,
|
||||
faQuestionCircle,
|
||||
faMapMarkerAlt,
|
||||
faBiking,
|
||||
faClock,
|
||||
faShoppingCart,
|
||||
faMoneyBillWave,
|
||||
faCalendarAlt,
|
||||
faSync,
|
||||
faGift,
|
||||
faLightbulb,
|
||||
faCheck,
|
||||
faExclamationTriangle,
|
||||
faLeaf,
|
||||
faWind
|
||||
} 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: any) => {
|
||||
return {
|
||||
name: item.produit || item.product_name || item.name_product || 'Produit',
|
||||
quantity: item.quantite || item.quantity || 0, // Grammes
|
||||
price: 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 'support':
|
||||
return 'linear-gradient(135deg, #e9d5ff 0%, #c084fc 50%, #9333ea 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é',
|
||||
'support': 'Pris en charge par le livreur',
|
||||
'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): any => {
|
||||
const iconMap: Record<string, any> = {
|
||||
'pending': faHourglassHalf,
|
||||
'assigned': faBiking,
|
||||
'support': faTruck,
|
||||
'en_route': faTruck,
|
||||
'arrived': faMapMarkerAlt,
|
||||
'livre': faBox,
|
||||
'approved': faCheckCircle,
|
||||
'cancelled': faTimesCircle
|
||||
};
|
||||
|
||||
return iconMap[status?.toLowerCase()] || faQuestionCircle;
|
||||
};
|
||||
|
||||
const getStatusProgress = (status: string): number => {
|
||||
const progressMap: Record<string, number> = {
|
||||
'pending': 0,
|
||||
'assigned': 10,
|
||||
'support': 25,
|
||||
'en_route': 50,
|
||||
'arrived': 80,
|
||||
'livre': 90,
|
||||
'approved': 100,
|
||||
'cancelled': 0
|
||||
};
|
||||
|
||||
return progressMap[status?.toLowerCase()] || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Calculer les points avec le TOTAL (pas item par item)
|
||||
*/
|
||||
const calculateOrderPoints = (order: OrderWithTracking): {
|
||||
points: number;
|
||||
category: string;
|
||||
categoryDisplay: string;
|
||||
categoryIcon: any;
|
||||
categoryColor: string;
|
||||
} => {
|
||||
let zipetteTotal = 0;
|
||||
let weedTotal = 0;
|
||||
let grosSemiTotal = 0;
|
||||
|
||||
// ✅ Calculer les totaux par catégorie
|
||||
if (order.items && order.items.length > 0) {
|
||||
order.items.forEach((item: any) => {
|
||||
const category = (item.category || '').toLowerCase();
|
||||
const itemPrice = item.prix || item.price || 0;
|
||||
|
||||
if (category.includes('zipette')) {
|
||||
zipetteTotal += itemPrice;
|
||||
} else if (category.includes('gros') || category.includes('semi')) {
|
||||
grosSemiTotal += itemPrice;
|
||||
} else {
|
||||
weedTotal += itemPrice;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`💰 [CALC_POINTS] Cmd ${order.id} - Zipette: ${zipetteTotal.toFixed(2)}€, Weed: ${weedTotal.toFixed(2)}€, GrosSemi: ${grosSemiTotal.toFixed(2)}€`);
|
||||
|
||||
let points = 0;
|
||||
let category = '';
|
||||
let categoryDisplay = '';
|
||||
let categoryIcon = faGift;
|
||||
let categoryColor = '#7c3aed';
|
||||
|
||||
// ✅ Gros&Semi = 0 points
|
||||
if (grosSemiTotal > 0 && zipetteTotal === 0 && weedTotal === 0) {
|
||||
return {
|
||||
points: 0,
|
||||
category: 'gros&semi',
|
||||
categoryDisplay: 'Gros&Semi',
|
||||
categoryIcon: faBox,
|
||||
categoryColor: '#9ca3af'
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ Zipette > Weed → Barème Zipette
|
||||
if (zipetteTotal > weedTotal) {
|
||||
category = 'zipette&co';
|
||||
categoryDisplay = 'Zipette&Co';
|
||||
categoryIcon = faWind;
|
||||
categoryColor = '#3b82f6';
|
||||
|
||||
if (zipetteTotal >= 30 && zipetteTotal <= 100) {
|
||||
points = 1;
|
||||
} else if (zipetteTotal >= 110 && zipetteTotal <= 200) {
|
||||
points = 2;
|
||||
} else if (zipetteTotal >= 210) {
|
||||
points = 3;
|
||||
}
|
||||
|
||||
// ✅ Weed > Zipette → Barème Weed
|
||||
} else if (weedTotal > 0) {
|
||||
category = 'weed&hash';
|
||||
categoryDisplay = 'Weed&Hash';
|
||||
categoryIcon = faLeaf;
|
||||
categoryColor = '#10b981';
|
||||
|
||||
if (weedTotal >= 30 && weedTotal <= 50) {
|
||||
points = 1;
|
||||
} else if (weedTotal >= 60 && weedTotal <= 150) {
|
||||
points = 2;
|
||||
} else if (weedTotal >= 160 && weedTotal <= 300) {
|
||||
points = 3;
|
||||
} else if (weedTotal >= 310 && weedTotal <= 400) {
|
||||
points = 5;
|
||||
} else if (weedTotal >= 400) {
|
||||
points = 10;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎁 [CALC_POINTS] Cmd ${order.id} - ${category} (${zipetteTotal + weedTotal}€) → ${points} points`);
|
||||
|
||||
return { points, category, categoryDisplay, categoryIcon, categoryColor };
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 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);
|
||||
|
||||
// ✅ 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);
|
||||
}, []);
|
||||
|
||||
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 (err) {
|
||||
console.warn(`Tracking non disponible pour commande ${order.id}`);
|
||||
tracking = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
eta = await getOrderETA(order.id);
|
||||
} catch (err) {
|
||||
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: any) {
|
||||
console.error('Erreur loadOrders:', err);
|
||||
setError(err.message || 'Erreur lors du chargement');
|
||||
showToast(err.message || 'Erreur lors du chargement', '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);
|
||||
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 responseData = (response as any).data || {};
|
||||
const apiCategory = responseData.category || '';
|
||||
|
||||
let displayCategory = selectedOrderCategoryDisplay;
|
||||
if (apiCategory.toLowerCase().includes('zipette')) {
|
||||
displayCategory = '💨 Zipette&Co';
|
||||
} else if (apiCategory.toLowerCase().includes('weed') || apiCategory.toLowerCase().includes('hash')) {
|
||||
displayCategory = '🌿 Weeds&Hash';
|
||||
}
|
||||
|
||||
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: any) {
|
||||
setError(err.message || 'Erreur serveur');
|
||||
showToast(err.message || 'Erreur serveur', '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: any) {
|
||||
console.error('❌ [CANCEL] Erreur:', error);
|
||||
showToast(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">
|
||||
<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) => (
|
||||
<div key={order.id} className="order-card">
|
||||
<div
|
||||
className="order-header"
|
||||
onClick={() => setExpandedOrder(expandedOrder === order.id ? null : order.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="order-header-left">
|
||||
<h3>Commande #{order.id}</h3>
|
||||
<div
|
||||
className="status-badge"
|
||||
style={{ backgroundImage: getStatusColor(order.status) }}
|
||||
>
|
||||
<FontAwesomeIcon icon={getStatusIcon(order.status)} /> {getStatusLabel(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-header-right">
|
||||
<span className="order-total2">
|
||||
{getTotalAmount(order).toFixed(2)} €
|
||||
</span>
|
||||
<span className="expand-icon">
|
||||
{expandedOrder === order.id ? '▲' : '▼'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedOrder === order.id && (
|
||||
<div className="order-details">
|
||||
<div className="status-progress">
|
||||
<div
|
||||
className="status-progress-bar"
|
||||
style={{
|
||||
width: `${getStatusProgress(order.status)}%`,
|
||||
backgroundImage: getStatusColor(order.status)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4><FontAwesomeIcon icon={faMapMarkerAlt} /> Adresse de livraison</h4>
|
||||
<p className="address">
|
||||
{getDeliveryAddress(order)}
|
||||
</p>
|
||||
{(() => {
|
||||
const clientInfo = getClientInfo(order);
|
||||
if (clientInfo.firstName || clientInfo.lastName) {
|
||||
return (
|
||||
<p className="contact">
|
||||
{clientInfo.firstName} {clientInfo.lastName}
|
||||
{clientInfo.phone && ` • ${clientInfo.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.eta && (
|
||||
<div className="detail-section">
|
||||
<h4><FontAwesomeIcon icon={faClock} /> Heure estimée d'arrivée</h4>
|
||||
<p>{order.eta.estimated_arrival || `${order.eta.eta_minutes} minutes`}</p>
|
||||
{order.eta.updated_at && (
|
||||
<small>Mise à jour: {new Date(order.eta.updated_at * 1000).toLocaleTimeString()}</small>
|
||||
)}
|
||||
</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: any, 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 className="order-actions">
|
||||
{order.status?.toLowerCase() === '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>
|
||||
) : order.status?.toLowerCase() === 'approved' ? (
|
||||
<div className="status-message success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Commande validée et confirmée
|
||||
</div>
|
||||
) : order.status?.toLowerCase() === 'cancelled' ? (
|
||||
<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 className="footer-info">
|
||||
<p><FontAwesomeIcon icon={faLightbulb} /> Les informations se mettent à jour automatiquement chaque 10 secondes</p>
|
||||
<p><FontAwesomeIcon icon={faGift} /> Points de fidélité selon votre achat : 💨 Zipette&Co (1-3 pts) • 🌿 Weed&Hash (1-10 pts)</p>
|
||||
</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;
|
||||
Reference in New Issue
Block a user