chore: update

This commit is contained in:
2026-03-03 23:42:23 +01:00
parent 52b059fafa
commit 0073f80a48
95 changed files with 2720 additions and 40619 deletions
+5 -88
View File
@@ -11,46 +11,19 @@ import ConsultationHistorique from "./pages/User/ConsultationHistorique";
import ProductDetail from "./pages/User/ProductDetail";
import Cart from "./pages/User/Cart";
import Checkout from "./pages/User/Checkout";
import OrderDetails from "./pages/User/OrderDetails";
import DashboardAdmin from "./pages/Admin/Dashboard";
// Pages Login
import LoginClient from "./pages/LoginClient/Login";
import RegisterClient from "./pages/RegisterClient/Register";
import LoginAdmin from "./pages/LoginAdmin/Login";
import AdminOrders from "./pages/AdminOrders/AdminOrders";
import AdminUsers from "./pages/AdminUsers/AdminUsers";
import AdminProduct from "./pages/AdminProduct/AdminProduct";
import AdminDeliverymen from "./pages/AdminManageDeliveryMan/AdminDeliveryMen";
import LoginPageCabinne from "./pages/LoginPageCabinne/LoginPage";
import CabineDashboard from "./pages/Cabine/CabineDashboard";
import CabineOrders from "./pages/CabineOrders/CabineOrders";
import CabineDeliverymen from "./pages/CabineDeliveryMen/CabineDeliveryMen";
import CabineUserManage from "./pages/CabineUserManager/UserManagement";
import DeliveryLogin from "./pages/LoginLivreur/LoginLivreur";
import DeliveryDashboard from "./pages/Livreur/DeliveryDashboard";
import StatsPage from "./pages/Livreur/StatsPage";
import OrderDetails from "./pages/User/OrderDetails";
import AlertHistory from "./pages/Livreur/AlertHistory";
import CabineAlerts from "./pages/CabineAlert/CabineAlerts";
import AdminAlerts from "./pages/AdminAlerts/AdminAlerts";
import ChangePasswordPage from "./pages/ChangePassword/ChangePassword";
function App() {
return (
<Router>
<Routes>
{/* Routes Login - SANS CartProvider */}
<Route path="/login/client" element={<LoginClient />} />
<Route path="/register/client" element={<RegisterClient />} />
{/* Routes login - Admin */}
<Route path="/login-admin/admin" element={<LoginAdmin />} />
<Route
path="/login-cabine/cabine"
element={<LoginPageCabinne />}
/>
<Route
path="/login-delivery/delivery"
element={<DeliveryLogin />}
/>
<Route path="/user/change-password" element={<ChangePasswordPage />} />
<Route
path="*"
@@ -59,30 +32,7 @@ function App() {
<Routes>
{/* Page d'accueil */}
<Route path="/" element={<Home />} />
<Route
path="/admin/dashboard"
element={<DashboardAdmin />}
/>
<Route
path="/admin/dashboard/orders"
element={<AdminOrders />}
/>
<Route
path="/admin/dashboard/users"
element={<AdminUsers />}
/>
<Route
path="/admin/dashboard/products"
element={<AdminProduct />}
/>
<Route
path="/admin/dashboard/delivery"
element={<AdminDeliverymen />}
/>
<Route
path="/admin/dashboard/alerts"
element={<AdminAlerts />}
/>
{/* Routes User - Panier */}
<Route path="/user/panier" element={<Cart />} />
<Route
@@ -121,39 +71,6 @@ function App() {
path="/user/commande/:orderId"
element={<OrderDetails />}
/>
<Route
path="/cabine/dashboard"
element={<CabineDashboard />}
/>
<Route
path="/cabine/dashboard/orders"
element={<CabineOrders />}
/>
<Route
path="/cabine/dashboard/delivery"
element={<CabineDeliverymen />}
/>
<Route
path="/cabine/dashboard/users"
element={<CabineUserManage />}
/>
<Route
path="/cabine/dashboard/alerts"
element={<CabineAlerts />}
/>
<Route
path="/delivery/dashboard"
element={<DeliveryDashboard />}
/>
<Route
path="/delivery/stats"
element={<StatsPage />}
/>
<Route
path="/delivery/alerts"
element={<AlertHistory />}
/>
</Routes>
</CartProvider>
}
+119 -94
View File
@@ -5,7 +5,7 @@
// ✅ loginUser et registerUser retournent AuthResponse
// ✅ sessionStorage (pas localStorage)
const API_URL = "/api/v1";
const API_URL = "https://uber-stup.club/api/v1";
import type {
ConfirmReceptionResponse,
CheckoutCartResponse,
@@ -36,6 +36,7 @@ export interface AuthResponse {
telephone?: string;
role?: string;
session_id?: string;
must_change_password?: boolean;
};
}
@@ -177,11 +178,16 @@ export const loginUser = async (
let errorMessage = "Erreur de connexion";
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
errorMessage =
errorData.error || errorData.message || errorMessage;
} catch {
// body vide ou non-JSON
}
console.error("❌ [LOGIN] Erreur API:", response.status, errorMessage);
console.error(
"❌ [LOGIN] Erreur API:",
response.status,
errorMessage,
);
return {
success: false,
message: errorMessage,
@@ -235,97 +241,6 @@ export const loginUser = async (
}
};
/**
* ✅ REGISTER - Retourne AuthResponse avec access_token
* POST /api/v1/auth/register
*/
export const registerUser = async (
username: string,
password: string,
nom: string,
prenom: string,
telephone: string,
): Promise<AuthResponse> => {
// ✅ Type de retour CORRECT
try {
console.log("📝 [REGISTER] Appel API...");
const response = await fetch(`${API_URL}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
nom,
prenom,
telephone,
}),
});
if (!response.ok) {
let errorMessage = "Erreur d'inscription";
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch {
// body vide ou non-JSON
}
console.error("❌ [REGISTER] Erreur API:", response.status, errorMessage);
return {
success: false,
message: errorMessage,
};
}
const data = await response.json();
console.log("📋 [REGISTER] Réponse:", data);
// ✅ Vérifier access_token
if (!data.access_token) {
console.error("❌ [REGISTER] Pas de access_token");
return {
success: false,
message: "Token non reçu du serveur",
};
}
// ✅ Stocker en sessionStorage
sessionStorage.setItem("token", data.access_token);
console.log("✅ [REGISTER] Token stocké");
// ✅ Synchroniser username
const jwtUsername = syncUsernameFromJWT();
if (!jwtUsername) {
console.warn("⚠️ [REGISTER] Impossible de synchroniser username");
return {
success: false,
message: "Erreur synchronisation JWT",
};
}
console.log(`✅ [REGISTER] Créé et connecté: ${jwtUsername}`);
// ✅ Retourner avec access_token
return {
success: true,
message: "Inscription réussie",
access_token: data.access_token, // ✅ IMPORTANT!
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error) {
console.error("❌ [REGISTER] Erreur:", error);
return {
success: false,
message:
error instanceof Error ? error.message : "Erreur d'inscription",
};
}
};
/**
* ✅ LOGOUT
*/
@@ -351,6 +266,43 @@ export const logoutUser = async (): Promise<void> => {
console.log("✅ [LOGOUT] sessionStorage nettoyé");
};
/**
* ✅ CHANGE PASSWORD
* PUT /api/v1/auth/change-password
*/
export const changePassword = async (
currentPassword: string,
newPassword: string,
): Promise<{ success: boolean; message: string }> => {
const token = sessionStorage.getItem("token");
try {
const response = await fetch(`${API_URL}/auth/change-password`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
message: data.error || data.message || "Erreur lors du changement de mot de passe",
};
}
return { success: true, message: data.message || "Mot de passe mis à jour" };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Erreur de connexion",
};
}
};
// ============================================
// 🛒 PANIER
// ============================================
@@ -1765,3 +1717,76 @@ export const checkoutCart = async (
};
}
};
// ============================================
// 🔔 NOTIFICATIONS CLIENT
// ============================================
export interface ClientNotification {
command_id: number;
type: string;
message: string;
created_at: string;
read: boolean;
}
export interface NotificationsResponse {
success: boolean;
notifications: ClientNotification[];
unread_count: number;
total: number;
}
export const getClientNotifications =
async (): Promise<NotificationsResponse> => {
const token = getAuthToken();
if (!token)
return {
success: false,
notifications: [],
unread_count: 0,
total: 0,
};
try {
const response = await fetch(`${API_URL}/notifications`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok)
return {
success: false,
notifications: [],
unread_count: 0,
total: 0,
};
const data = await response.json();
return {
success: true,
notifications: data.notifications || [],
unread_count: data.unread_count || 0,
total: data.total || 0,
};
} catch {
return {
success: false,
notifications: [],
unread_count: 0,
total: 0,
};
}
};
export const markNotificationsRead = async (): Promise<{
success: boolean;
}> => {
const token = getAuthToken();
if (!token) return { success: false };
try {
const response = await fetch(`${API_URL}/notifications/read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
return { success: response.ok };
} catch {
return { success: false };
}
};
File diff suppressed because it is too large Load Diff
-363
View File
@@ -1,363 +0,0 @@
export interface AdminLogin {
username: string;
password: string;
}
export interface ApiResponse {
success: boolean;
message?: string;
error?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: AdminResponse;
[key: string]: any;
}
export interface AdminResponse {
id: number;
username: string;
role: string;
}
/**
* ✅ Deliveryman Location Response
*/
export interface DeliverymanLocation {
latitude: number;
longitude: number;
last_update: number;
last_update_ago: number;
is_recent: boolean;
}
export interface DeliverymanInfo {
username: string;
status: string;
current_command: number;
queue_size: number;
location: DeliverymanLocation;
}
export interface CommandETA {
minutes: number;
has_eta: boolean;
set_at: number;
}
export interface DeliverymanLocationResponse {
success: boolean;
data?: {
command_id: number;
client: string;
command_status: string;
deliveryman: DeliverymanInfo;
eta: CommandETA;
};
requested_by?: {
username: string;
role: string;
};
error?: string;
message?: string;
command_info?: {
command_id: number;
client: string;
deliveryman?: string;
status: string;
};
}
export interface ProductPrice {
id?: number;
product_id?: number;
quantity: number;
price: number;
created_at?: string;
}
export interface Media {
id?: number;
product_id?: number;
type: string;
url: string;
}
export interface Product {
id?: number;
name: string;
category: string;
description: string;
stock: number;
prices: ProductPrice[];
media?: Media[];
created_at?: string;
updated_at?: string;
}
export interface ProductResponse {
success: boolean;
message?: string;
product?: Product;
data?: Product;
error?: string;
}
export interface ProductListResponse {
success: boolean;
data?: Product[];
count?: number;
message?: string;
error?: string;
}
export interface CreateProductData {
name: string;
category: string;
description: string;
stock: number;
prices: ProductPrice[];
media?: File[];
}
/**
* ✅ Informations détaillées d'un livreur
*/
export interface DeliveryPersonDetails {
id: number;
username: string;
role: string;
status: "available" | "busy" | "offline";
current_command?: number | null;
queue_size: number;
total_deliveries?: number;
completed_deliveries?: number;
pending_deliveries?: number;
location?: {
latitude: number;
longitude: number;
last_update: number;
last_update_ago: number;
is_recent: boolean;
};
created_at?: string;
updated_at?: string;
}
/**
* ✅ Statistiques d'un livreur
*/
export interface DeliveryPersonStats {
username: string;
total_deliveries: number;
completed_deliveries: number;
cancelled_deliveries: number;
pending_deliveries: number;
in_progress_deliveries: number;
average_delivery_time?: number; // en minutes
success_rate?: number; // en pourcentage
total_distance?: number; // en km
current_queue_size: number;
last_delivery_date?: string;
status: string;
}
/**
* ✅ Historique des livraisons d'un livreur
*/
export interface DeliveryHistory {
command_id: number;
client: string;
status: string;
adresse: string;
total_prix: number;
assigned_at: string;
completed_at?: string;
delivery_time?: number; // en minutes
distance?: number; // en km
}
/**
* ✅ Position GPS d'un livreur
*/
export interface DeliveryPersonLocation {
username: string;
latitude: number;
longitude: number;
last_update: number;
last_update_ago: number;
is_recent: boolean;
status: string;
}
export interface DeliverymanLocation {
latitude: number;
longitude: number;
last_update: number;
last_update_ago: number;
is_recent: boolean;
}
/**
* ✅ Statistiques d'un livreur
*/
export interface DeliveryPersonStats {
username: string;
total_deliveries: number;
completed_deliveries: number;
cancelled_deliveries: number;
pending_deliveries: number;
in_progress_deliveries: number;
average_delivery_time?: number; // en minutes
success_rate?: number; // en pourcentage
total_distance?: number; // en km
current_queue_size: number;
last_delivery_date?: string;
status: string;
}
/**
* ✅ Historique des livraisons d'un livreur
*/
export interface DeliveryHistory {
command_id: number;
client: string;
status: string;
adresse: string;
total_prix: number;
assigned_at: string;
completed_at?: string;
delivery_time?: number; // en minutes
distance?: number; // en km
}
/**
* ✅ Position GPS d'un livreur
*/
export interface DeliveryPersonLocation {
username: string;
latitude: number;
longitude: number;
last_update: number;
last_update_ago: number;
is_recent: boolean;
status: string;
}
/**
* ✅ Réponse position livreur pour une commande
*/
export interface DeliverymanLocationResponse {
success: boolean;
data?: {
command_id: number;
client: string;
command_status: string;
deliveryman: DeliverymanInfo;
eta: CommandETA;
};
requested_by?: {
username: string;
role: string;
};
error?: string;
message?: string;
command_info?: {
command_id: number;
client: string;
deliveryman?: string;
status: string;
};
}
/**
* ✅ Liste des livreurs disponibles
*/
export interface DeliveryPersonsListResponse {
success: boolean;
livreurs: DeliveryPersonDetails[];
count: number;
}
// ============================================
// 📦 INTERFACES LIVREURS - CABINE & ADMIN
// ============================================
/**
* ✅ Livreur avec détails complets (pour liste)
*/
export interface DeliveryPerson {
id: number;
username: string;
nom?: string;
prenom?: string;
telephone?: string;
status: "available" | "busy" | "offline";
location: {
latitude: number;
longitude: number;
last_update: string;
is_recent: boolean;
};
stats: {
total_deliveries: number;
completed_today: number;
queue_size: number;
current_command: number | null;
};
}
/**
* ✅ Stats globales des livreurs
*/
export interface DeliveryPersonsStats {
total: number;
available: number;
busy: number;
offline: number;
active_deliveries: number;
}
/**
* ✅ Réponse API getAllDeliveryPersonsWithDetails
*/
export interface AllDeliveryPersonsResponse {
success: boolean;
deliveryPersons: DeliveryPerson[];
count: number;
stats: DeliveryPersonsStats;
error?: string;
}
/**
* ✅ Liens de navigation GPS
*/
export interface MapLinks {
google_maps: string;
waze: string;
apple_maps: string;
openstreetmap: string;
}
/**
* ✅ Réponse API getDeliveryPersonMapLinks
*/
export interface MapLinksResponse {
success: boolean;
deliveryman?: string;
location?: {
latitude: number;
longitude: number;
last_update?: number;
is_recent: boolean;
};
map_links?: MapLinks;
error?: string;
message?: string;
}
export interface DeleteResponse {
success: boolean;
message?: string;
error?: string;
}
File diff suppressed because it is too large Load Diff
-727
View File
@@ -1,727 +0,0 @@
// ============================================
// api/api_livreur.ts - LIVREUR API HELPERS
// ============================================
// ✅ Fonctions helper pour le dashboard livreur
// ✅ Utilise /api/v1/livreur/* endpoints
const API_URL = "/api/v1/livreur";
// ============================================
// 🔐 TYPES - LIVREUR
// ============================================
export interface DeliveryStatus {
status: "available" | "busy" | "offline";
current_command?: number;
last_update?: number;
}
export interface QueueInfo {
queue_size: number;
commands: any[];
}
export interface DeliveryItem {
id: number;
status: string;
adresse: string;
total_prix: number;
created_at: string;
updated_at: string;
eta?: string;
}
export interface ClientInfo {
username: string;
nom?: string;
prenom?: string;
telephone?: string;
}
export interface DeliveryDetails {
delivery: DeliveryItem;
client_info: ClientInfo;
}
export interface Alert {
id: number;
username: string;
status: string;
created_at: string;
updated_at: string;
}
// ============================================
// 🔐 GESTION JWT
// ============================================
/**
* ✅ Récupérer le token d'authentification
*/
const getAuthToken = (): string | null => {
return sessionStorage.getItem("admin_token");
};
export const isDeliveryAuthenticated = (): boolean => {
const token = sessionStorage.getItem("admin_token");
return !!token;
};
// ============================================
// 📊 STATUT DU LIVREUR
// ============================================
/**
* ✅ Récupérer le statut actuel du livreur
* GET /api/v1/livreur/status
*/
export const getMyStatus = async (): Promise<{
success: boolean;
status?: DeliveryStatus;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_MY_STATUS] Appel API");
const response = await fetch(`${API_URL}/status`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_MY_STATUS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_MY_STATUS] Réponse:", data);
return {
success: true,
status: data.status,
};
} catch (error) {
console.error("❌ [GET_MY_STATUS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Mettre à jour le statut du livreur
* POST /api/v1/livreur/status
*/
export const updateMyStatus = async (
status: "available" | "busy" | "offline",
): Promise<{ success: boolean; message?: string; error?: string }> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("📝 [UPDATE_MY_STATUS] Appel API:", status);
const response = await fetch(`${API_URL}/update/status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status }),
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [UPDATE_MY_STATUS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [UPDATE_MY_STATUS] Réponse:", data);
return {
success: true,
message: data.message,
};
} catch (error) {
console.error("❌ [UPDATE_MY_STATUS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
// ============================================
// 📦 QUEUE DU LIVREUR
// ============================================
/**
* ✅ Récupérer la queue de livraisons du livreur
* GET /api/v1/livreur/queue
*/
export const getMyQueue = async (): Promise<{
success: boolean;
queue_info?: QueueInfo;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_MY_QUEUE] Appel API");
const response = await fetch(`${API_URL}/queue`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_MY_QUEUE] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_MY_QUEUE] Réponse:", data);
return {
success: true,
queue_info: data.queue_info,
};
} catch (error) {
console.error("❌ [GET_MY_QUEUE] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
// ============================================
// 🚚 LIVRAISONS
// ============================================
/**
* ✅ Récupérer toutes les livraisons du livreur
* GET /api/v1/livreur/deliveries
*/
export const getMyDeliveries = async (): Promise<{
success: boolean;
deliveries?: DeliveryItem[];
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_MY_DELIVERIES] Appel API");
const response = await fetch(`${API_URL}/deliveries`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_MY_DELIVERIES] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_MY_DELIVERIES] Réponse:", data);
return {
success: true,
deliveries: data.deliveries || [],
};
} catch (error) {
console.error("❌ [GET_MY_DELIVERIES] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Récupérer les détails d'une livraison spécifique
* GET /api/v1/livreur/deliveries/:id
*/
export const getDeliveryDetails = async (
deliveryId: number,
): Promise<{
success: boolean;
delivery?: DeliveryDetails;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_DELIVERY_DETAILS] Appel API:", deliveryId);
const response = await fetch(`${API_URL}/deliveries/${deliveryId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_DELIVERY_DETAILS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_DELIVERY_DETAILS] Réponse:", data);
return {
success: true,
delivery: {
delivery: data.delivery,
client_info: data.client_info,
},
};
} catch (error) {
console.error("❌ [GET_DELIVERY_DETAILS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Démarrer une livraison
* POST /api/v1/livreur/deliveries/:id/start
*/
export const startDelivery = async (
deliveryId: number,
latitude: number,
longitude: number,
): Promise<{ success: boolean; message?: string; error?: string }> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🚀 [START_DELIVERY] Appel API:", deliveryId);
const response = await fetch(
`${API_URL}/deliveries/${deliveryId}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ latitude, longitude }),
},
);
const data = await response.json();
if (!response.ok) {
console.error("❌ [START_DELIVERY] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [START_DELIVERY] Réponse:", data);
return {
success: true,
message: data.message,
};
} catch (error) {
console.error("❌ [START_DELIVERY] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Mettre à jour le statut d'une livraison
* PUT /api/v1/livreur/deliveries/:id/status
*/
export const updateDeliveryStatus = async (
deliveryId: number,
status: string,
latitude: number,
longitude: number,
): Promise<{ success: boolean; message?: string; error?: string }> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log(
"📝 [UPDATE_DELIVERY_STATUS] Appel API:",
deliveryId,
status,
);
const response = await fetch(
`${API_URL}/deliveries/${deliveryId}/status`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status, latitude, longitude }),
},
);
const data = await response.json();
if (!response.ok) {
console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [UPDATE_DELIVERY_STATUS] Réponse:", data);
return {
success: true,
message: data.message,
};
} catch (error) {
console.error("❌ [UPDATE_DELIVERY_STATUS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
// ============================================
// 📍 POSITION GPS
// ============================================
/**
* ✅ Mettre à jour la position GPS du livreur
* POST /api/v1/livreur/location/update
*/
export const updateMyLocation = async (
latitude: number,
longitude: number,
): Promise<{
success: boolean;
message?: string;
status?: string;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
const response = await fetch(`${API_URL}/location/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ latitude, longitude }),
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [UPDATE_MY_LOCATION] Erreur API:", data);
return { success: false, error: data.error };
}
return {
success: true,
message: data.message,
status: data.status,
};
} catch (error) {
console.error("❌ [UPDATE_MY_LOCATION] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Récupérer la position actuelle du livreur
* GET /api/v1/livreur/location
*/
export const getMyLocation = async (): Promise<{
success: boolean;
latitude?: number;
longitude?: number;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
const response = await fetch(`${API_URL}/location`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_MY_LOCATION] Erreur API:", data);
return { success: false, error: data.error };
}
return {
success: true,
latitude: data.latitude,
longitude: data.longitude,
};
} catch (error) {
console.error("❌ [GET_MY_LOCATION] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
// ============================================
// 🚨 ALERTES POLICE
// ============================================
/**
* ✅ Déclencher une alerte police
* POST /api/v1/livreur/alert
*/
export const triggerPoliceAlert = async (): Promise<{
success: boolean;
alert_id?: number;
message?: string;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🚨 [TRIGGER_POLICE_ALERT] Déclenchement alerte police");
const response = await fetch(`${API_URL}/alert`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [TRIGGER_POLICE_ALERT] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [TRIGGER_POLICE_ALERT] Alerte créée:", data);
return {
success: true,
alert_id: data.alert_id,
message: data.message,
};
} catch (error) {
console.error("❌ [TRIGGER_POLICE_ALERT] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Mettre fin à une alerte
* DELETE /api/v1/livreur/alert/:id
*/
export const endAlert = async (
alertId: number,
): Promise<{
success: boolean;
message?: string;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔚 [END_ALERT] Terminer alerte:", alertId);
const response = await fetch(`${API_URL}/alert/${alertId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [END_ALERT] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [END_ALERT] Alerte terminée:", data);
return {
success: true,
message: data.message,
};
} catch (error) {
console.error("❌ [END_ALERT] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Récupérer toutes mes alertes
* GET /api/v1/livreur/alerts
*/
export const getMyAlerts = async (): Promise<{
success: boolean;
alerts?: Alert[];
count?: number;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_MY_ALERTS] Récupération alertes");
const response = await fetch(`${API_URL}/alerts`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_MY_ALERTS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_MY_ALERTS] Alertes récupérées:", data);
return {
success: true,
alerts: data.alerts || [],
count: data.count || 0,
};
} catch (error) {
console.error("❌ [GET_MY_ALERTS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
/**
* ✅ Récupérer les détails d'une alerte
* GET /api/v1/livreur/alert/:id
*/
export const getAlertDetails = async (
alertId: number,
): Promise<{
success: boolean;
alert?: Alert;
error?: string;
}> => {
const token = getAuthToken();
if (!token) {
return { success: false, error: "Token non trouvé" };
}
try {
console.log("🔍 [GET_ALERT_DETAILS] Récupération alerte:", alertId);
const response = await fetch(`${API_URL}/alert/${alertId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [GET_ALERT_DETAILS] Erreur API:", data);
return { success: false, error: data.error };
}
console.log("✅ [GET_ALERT_DETAILS] Alerte récupérée:", data);
return {
success: true,
alert: data.alert,
};
} catch (error) {
console.error("❌ [GET_ALERT_DETAILS] Erreur fetch:", error);
return { success: false, error: "Erreur réseau" };
}
};
// ============================================
// 🔄 EXPORT PAR DÉFAUT
// ============================================
export default {
// Statut
getMyStatus,
updateMyStatus,
// Queue
getMyQueue,
// Livraisons
getMyDeliveries,
getDeliveryDetails,
startDelivery,
updateDeliveryStatus,
// Position GPS
updateMyLocation,
getMyLocation,
// Alertes Police
triggerPoliceAlert,
endAlert,
getMyAlerts,
getAlertDetails,
};
@@ -1,12 +0,0 @@
.admin-layout {
display: flex;
min-height: 100vh;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
}
.admin-content {
flex: 1;
margin-left: 0;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
}
@@ -1,19 +0,0 @@
import Sidebar from './Sidebar';
import './AdminLayout.css';
interface AdminLayoutProps {
children: React.ReactNode;
}
function AdminLayout({ children }: AdminLayoutProps) {
return (
<div className="admin-layout">
<Sidebar />
<main className="admin-content">
{children}
</main>
</div>
);
}
export default AdminLayout;
@@ -1,388 +0,0 @@
/* ============================================
CONFIRM MODAL - STYLES PROFESSIONNELS
============================================ */
.confirm-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 1rem;
animation: overlayFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ============================================
CONTAINER
============================================ */
.confirm-modal-container {
background: linear-gradient(
135deg,
rgba(30, 30, 40, 0.98) 0%,
rgba(20, 20, 30, 0.98) 100%
);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
width: 100%;
max-width: 480px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
animation: modalSlideIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: hidden;
}
@keyframes modalSlideIn {
from {
transform: scale(0.9) translateY(20px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* ============================================
HEADER
============================================ */
.confirm-modal-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 2rem 1.5rem 2rem;
}
.confirm-modal-icon {
width: 72px;
height: 72px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
animation: iconPulse 2s ease-in-out infinite;
}
@keyframes iconPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.confirm-modal-icon-danger {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
border: 2px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
box-shadow: 0 0 30px rgba(239, 68, 68, 0.3);
}
.confirm-modal-icon-warning {
background: linear-gradient(
135deg,
rgba(245, 158, 11, 0.2),
rgba(217, 119, 6, 0.1)
);
border: 2px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
box-shadow: 0 0 30px rgba(245, 158, 11, 0.3);
}
.confirm-modal-icon-info {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
border: 2px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
box-shadow: 0 0 30px rgba(59, 130, 246, 0.3);
}
.confirm-modal-icon-success {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
border: 2px solid rgba(16, 185, 129, 0.3);
color: #10b981;
box-shadow: 0 0 30px rgba(16, 185, 129, 0.3);
}
.confirm-modal-close {
position: absolute;
top: 1.5rem;
right: 1.5rem;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #888;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.confirm-modal-close:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
transform: rotate(90deg);
}
.confirm-modal-close:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================
CONTENT
============================================ */
.confirm-modal-content {
padding: 0 2rem 2rem 2rem;
text-align: center;
}
.confirm-modal-title {
color: white;
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1rem 0;
letter-spacing: -0.5px;
line-height: 1.3;
}
.confirm-modal-message {
color: rgba(255, 255, 255, 0.7);
font-size: 1rem;
line-height: 1.6;
margin: 0;
max-width: 380px;
margin: 0 auto;
}
/* ============================================
FOOTER
============================================ */
.confirm-modal-footer {
display: flex;
gap: 1rem;
padding: 0 2rem 2rem 2rem;
}
.confirm-modal-btn {
flex: 1;
padding: 1rem 1.5rem;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
}
.confirm-modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Cancel Button */
.confirm-modal-btn-cancel {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.confirm-modal-btn-cancel:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
/* Confirm Buttons - Variants */
.confirm-modal-btn-confirm {
font-weight: 700;
}
.confirm-modal-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: rgba(239, 68, 68, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.confirm-modal-btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #f87171, #ef4444);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(239, 68, 68, 0.5);
}
.confirm-modal-btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
border-color: rgba(245, 158, 11, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3);
}
.confirm-modal-btn-warning:hover:not(:disabled) {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(245, 158, 11, 0.5);
}
.confirm-modal-btn-info {
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-color: rgba(59, 130, 246, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
}
.confirm-modal-btn-info:hover:not(:disabled) {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.5);
}
.confirm-modal-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
border-color: rgba(16, 185, 129, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
}
.confirm-modal-btn-success:hover:not(:disabled) {
background: linear-gradient(135deg, #34d399, #10b981);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.5);
}
/* ============================================
LOADING STATE
============================================ */
.confirm-modal-loading {
display: flex;
align-items: center;
gap: 0.6rem;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 640px) {
.confirm-modal-container {
max-width: 100%;
margin: 1rem;
border-radius: 16px;
}
.confirm-modal-header {
padding: 1.5rem 1.5rem 1rem 1.5rem;
}
.confirm-modal-content {
padding: 0 1.5rem 1.5rem 1.5rem;
}
.confirm-modal-footer {
flex-direction: column;
padding: 0 1.5rem 1.5rem 1.5rem;
}
.confirm-modal-btn {
width: 100%;
}
.confirm-modal-icon {
width: 64px;
height: 64px;
}
.confirm-modal-title {
font-size: 1.3rem;
}
.confirm-modal-message {
font-size: 0.95rem;
}
.confirm-modal-close {
top: 1rem;
right: 1rem;
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
.confirm-modal-icon {
width: 56px;
height: 56px;
}
.confirm-modal-title {
font-size: 1.2rem;
}
}
/* Mobile Touch Optimization */
@media (hover: none) {
.confirm-modal-btn:hover {
transform: none;
}
.confirm-modal-close:hover {
transform: none;
}
}
@@ -1,114 +0,0 @@
import { X, AlertTriangle, Trash2, CheckCircle, Info } from "lucide-react";
import "./ConfirmModal.css";
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
type?: "danger" | "warning" | "info" | "success";
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
type = "warning",
confirmText = "Confirmer",
cancelText = "Annuler",
isLoading = false,
}: ConfirmModalProps) {
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case "danger":
return <Trash2 size={28} />;
case "warning":
return <AlertTriangle size={28} />;
case "success":
return <CheckCircle size={28} />;
case "info":
return <Info size={28} />;
default:
return <AlertTriangle size={28} />;
}
};
const handleConfirm = () => {
if (!isLoading) {
onConfirm();
}
};
const handleCancel = () => {
if (!isLoading) {
onClose();
}
};
return (
<div className="confirm-modal-overlay" onClick={handleCancel}>
<div
className="confirm-modal-container"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="confirm-modal-header">
<div
className={`confirm-modal-icon confirm-modal-icon-${type}`}
>
{getIcon()}
</div>
<button
className="confirm-modal-close"
onClick={handleCancel}
disabled={isLoading}
aria-label="Fermer"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="confirm-modal-content">
<h2 className="confirm-modal-title">{title}</h2>
<p className="confirm-modal-message">{message}</p>
</div>
{/* Footer */}
<div className="confirm-modal-footer">
<button
className="confirm-modal-btn confirm-modal-btn-cancel"
onClick={handleCancel}
disabled={isLoading}
>
{cancelText}
</button>
<button
className={`confirm-modal-btn confirm-modal-btn-confirm confirm-modal-btn-${type}`}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading ? (
<span className="confirm-modal-loading">
<span className="spinner" />
Chargement...
</span>
) : (
confirmText
)}
</button>
</div>
</div>
</div>
);
}
export default ConfirmModal;
@@ -1,429 +0,0 @@
/* ============================================
EditUserModal.css
============================================ */
/* ✅ Modal overlay - PAS DE Z-INDEX */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
/* ✅ PAS DE Z-INDEX pour ne pas cacher la modal */
backdrop-filter: blur(8px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.edit-user-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 700px;
max-height: 90vh;
background: linear-gradient(135deg, #1a1a1a, #0f0f0f);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 20px;
z-index: 9999; /* ✅ Seule la modal a un z-index */
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
/* Alerts */
.alert {
padding: 1rem 1.2rem;
border-radius: 10px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.8rem;
font-size: 0.95rem;
font-weight: 600;
animation: slideInDown 0.3s ease;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert-error {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.alert-success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
}
/* Form sections */
.form-section {
margin-bottom: 2rem;
}
.form-section:last-child {
margin-bottom: 0;
}
.form-section h3 {
color: white;
font-size: 1.1rem;
margin: 0 0 1rem 0;
font-weight: bold;
border-bottom: 2px solid rgba(124, 58, 237, 0.3);
padding-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
color: #888;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-group label svg {
color: #7c3aed;
}
.form-group input,
.form-group select {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.9rem 1rem;
color: white;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
}
.form-group input:focus,
.form-group select:focus {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.4);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.form-group input::placeholder {
color: #666;
}
.form-group input[type="number"] {
appearance: textfield;
}
.form-group input[type="number"]::-webkit-inner-spin-button,
.form-group input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.role-select {
cursor: pointer;
background: #1a1a1a !important;
width: 100%;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.custom-select-wrapper {
position: relative;
width: 100%;
}
.custom-select-wrapper .select-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #7c3aed;
display: flex;
align-items: center;
z-index: 1;
}
.custom-select-wrapper .select-arrow {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #888;
font-size: 0.7rem;
}
.role-select {
padding-left: 3rem !important;
}
.role-select option {
background-color: #1a1a1a !important;
color: white !important;
padding: 0.8rem !important;
}
.role-select option:hover,
.role-select option:focus,
.role-select option:active {
background-color: #0f0f0f !important;
background: #0f0f0f !important;
color: white !important;
outline: none !important;
box-shadow: none !important;
}
.role-select option:checked {
background-color: #7c3aed !important;
background: #7c3aed !important;
color: white !important;
font-weight: 600;
}
@-moz-document url-prefix() {
.role-select option:hover {
background-color: #0f0f0f !important;
}
}
.role-select::-ms-expand {
display: none;
}
.role-select option::selection {
background: #0f0f0f !important;
}
.role-select option::-moz-selection {
background: #0f0f0f !important;
}
/* Spinner */
.spinner {
width: 18px;
height: 18px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Modal header et actions - Styles manquants */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modal-header h2 {
color: white;
font-size: 1.5rem;
margin: 0;
font-weight: bold;
}
.close-modal {
background: transparent;
border: none;
color: #888;
cursor: pointer;
transition: all 0.3s ease;
padding: 0.5rem;
border-radius: 8px;
}
.close-modal:hover {
color: white;
background: rgba(255, 255, 255, 0.05);
transform: rotate(90deg);
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.modal-content::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 4px;
}
.modal-actions {
display: flex;
gap: 1rem;
padding: 1.5rem 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.action-button {
flex: 1;
min-width: 150px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.9rem 1.5rem;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
}
.action-button.secondary {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}
.action-button.secondary:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.action-button.primary {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border: 1px solid rgba(124, 58, 237, 0.3);
color: white;
}
.action-button.primary:hover {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Responsive */
@media (max-width: 768px) {
.edit-user-modal {
width: 95%;
max-height: 95vh;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-group {
grid-column: 1 / -1 !important;
}
.modal-header,
.modal-actions {
padding: 1rem 1.5rem;
}
.modal-content {
padding: 1.5rem;
}
.modal-actions {
flex-direction: column;
}
.action-button {
width: 100%;
}
}
@media (max-width: 480px) {
.form-section h3 {
font-size: 1rem;
}
.form-group label {
font-size: 0.8rem;
}
.form-group input,
.form-group select {
padding: 0.8rem;
font-size: 0.9rem;
}
}
@@ -1,407 +0,0 @@
// ============================================
// EditUserModal.tsx
// ============================================
// Modal de modification des profils utilisateurs (Admin)
import { useState } from 'react';
import { XCircle, Save, User, Lock, Shield, Phone, Crown, Building2, Truck } from 'lucide-react';
import { updateClientByAdmin, updateUserByAdmin } from '../api/api_admin';
import './EditUserModal.css';
interface EditUserModalProps {
user: {
id: number;
username: string;
role: 'client' | 'livreur' | 'admin' | 'cabine';
nom?: string;
prenom?: string;
telephone?: string;
adresse?: string;
command?: number;
point?: number;
points_zipette?: number; // ✅ AJOUTÉ
amende?: number;
};
onClose: () => void;
onSuccess: () => void;
}
function EditUserModal({ user, onClose, onSuccess }: EditUserModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// État du formulaire pour CLIENT
const [clientForm, setClientForm] = useState({
username: user.username || '',
password: '',
nom: user.nom || '',
prenom: user.prenom || '',
telephone: user.telephone || '',
point: user.point || 0,
points_zipette: user.points_zipette || 0, // ✅ AJOUTÉ
amende: user.amende || 0,
command: user.command || 0,
});
// État du formulaire pour USER (admin/cabine/livreur)
const [userForm, setUserForm] = useState({
username: user.username || '',
password: '',
role: user.role as 'admin' | 'cabine' | 'livreur',
});
const isClient = user.role === 'client';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
if (isClient) {
// ✅ Modification CLIENT
const updates: any = {};
// N'envoyer que les champs modifiés
if (clientForm.username && clientForm.username !== user.username) {
updates.username = clientForm.username;
}
if (clientForm.password) {
updates.password = clientForm.password;
}
if (clientForm.nom && clientForm.nom !== user.nom) {
updates.nom = clientForm.nom;
}
if (clientForm.prenom && clientForm.prenom !== user.prenom) {
updates.prenom = clientForm.prenom;
}
if (clientForm.telephone && clientForm.telephone !== user.telephone) {
updates.telephone = clientForm.telephone;
}
if (clientForm.point !== user.point) {
updates.point = clientForm.point;
}
if (clientForm.points_zipette !== user.points_zipette) { // ✅ AJOUTÉ
updates.points_zipette = clientForm.points_zipette;
}
if (clientForm.amende !== user.amende) {
updates.amende = clientForm.amende;
}
if (clientForm.command !== user.command) {
updates.command = clientForm.command;
}
if (Object.keys(updates).length === 0) {
setError('Aucune modification détectée');
setLoading(false);
return;
}
console.log('📝 [EDIT_CLIENT] Mise à jour:', updates);
const result = await updateClientByAdmin(user.id, updates);
if (result.success) {
setSuccess(result.message || 'Client mis à jour avec succès');
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
}
} else {
// ✅ Modification USER (admin/cabine/livreur)
const updates: any = {};
if (userForm.username && userForm.username !== user.username) {
updates.username = userForm.username;
}
if (userForm.password) {
updates.password = userForm.password;
}
if (userForm.role && userForm.role !== user.role) {
updates.role = userForm.role;
}
if (Object.keys(updates).length === 0) {
setError('Aucune modification détectée');
setLoading(false);
return;
}
console.log('📝 [EDIT_USER] Mise à jour:', updates);
const result = await updateUserByAdmin(user.id, updates);
if (result.success) {
setSuccess(result.message || 'Utilisateur mis à jour avec succès');
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
}
}
} catch (err) {
console.error('❌ [EDIT_MODAL] Erreur:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour');
} finally {
setLoading(false);
}
};
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="edit-user-modal">
<div className="modal-header">
<h2>
{isClient ? 'Modifier le client' : 'Modifier l\'utilisateur'}
</h2>
<button className="close-modal" onClick={onClose}>
<XCircle size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-content">
{/* Messages de succès/erreur */}
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{success && (
<div className="alert alert-success">
{success}
</div>
)}
{isClient ? (
// ============================================
// FORMULAIRE CLIENT
// ============================================
<>
<div className="form-section">
<h3>Informations personnelles</h3>
<div className="form-grid">
<div className="form-group">
<label>
<User size={16} />
<span>Nom d'utilisateur</span>
</label>
<input
type="text"
value={clientForm.username}
onChange={(e) => setClientForm({ ...clientForm, username: e.target.value })}
placeholder="Nom d'utilisateur"
/>
</div>
<div className="form-group">
<label>
<Lock size={16} />
<span>Nouveau mot de passe</span>
</label>
<input
type="password"
value={clientForm.password}
onChange={(e) => setClientForm({ ...clientForm, password: e.target.value })}
placeholder="Laisser vide pour ne pas changer"
minLength={8}
/>
</div>
<div className="form-group">
<label>
<User size={16} />
<span>Nom</span>
</label>
<input
type="text"
value={clientForm.nom}
onChange={(e) => setClientForm({ ...clientForm, nom: e.target.value })}
placeholder="Nom"
/>
</div>
<div className="form-group">
<label>
<User size={16} />
<span>Prénom</span>
</label>
<input
type="text"
value={clientForm.prenom}
onChange={(e) => setClientForm({ ...clientForm, prenom: e.target.value })}
placeholder="Prénom"
/>
</div>
<div className="form-group full-width">
<label>
<Phone size={16} />
<span>Téléphone</span>
</label>
<input
type="tel"
value={clientForm.telephone}
onChange={(e) => setClientForm({ ...clientForm, telephone: e.target.value })}
placeholder="+33612345678"
/>
</div>
</div>
</div>
<div className="form-section">
<h3>Statistiques (Admin uniquement)</h3>
<div className="form-grid">
<div className="form-group">
<label>
<span>📦</span>
<span>Commandes</span>
</label>
<input
type="number"
value={clientForm.command}
onChange={(e) => setClientForm({ ...clientForm, command: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>🍃</span>
<span>Points Weed/Hash</span>
</label>
<input
type="number"
value={clientForm.point}
onChange={(e) => setClientForm({ ...clientForm, point: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>💨</span>
<span>Points Zipette</span>
</label>
<input
type="number"
value={clientForm.points_zipette}
onChange={(e) => setClientForm({ ...clientForm, points_zipette: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>⚠️</span>
<span>Amendes</span>
</label>
<input
type="number"
step="0.01"
value={clientForm.amende}
onChange={(e) => setClientForm({ ...clientForm, amende: parseFloat(e.target.value) || 0 })}
min="0"
/>
</div>
</div>
</div>
</>
) : (
// ============================================
// FORMULAIRE USER (admin/cabine/livreur)
// ============================================
<div className="form-section">
<h3>Informations utilisateur</h3>
<div className="form-grid">
<div className="form-group">
<label>
<User size={16} />
<span>Nom d'utilisateur</span>
</label>
<input
type="text"
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
placeholder="Nom d'utilisateur"
/>
</div>
<div className="form-group">
<label>
<Lock size={16} />
<span>Nouveau mot de passe</span>
</label>
<input
type="password"
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
placeholder="Laisser vide pour ne pas changer"
minLength={8}
/>
</div>
<div className="form-group full-width">
<label>
<Shield size={16} />
<span>Rôle</span>
</label>
<div className="custom-select-wrapper">
<select
value={userForm.role}
onChange={(e) => setUserForm({ ...userForm, role: e.target.value as any })}
className="role-select"
>
<option value="admin">Administrateur</option>
<option value="cabine">Opérateur Cabine</option>
<option value="livreur">Livreur</option>
</select>
<div className="select-icon">
{userForm.role === 'admin' && <Crown size={16} />}
{userForm.role === 'cabine' && <Building2 size={16} />}
{userForm.role === 'livreur' && <Truck size={16} />}
</div>
<div className="select-arrow"></div>
</div>
</div>
</div>
</div>
)}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
disabled={loading}
>
{loading ? (
<>
<span className="spinner" />
<span>Enregistrement...</span>
</>
) : (
<>
<Save size={18} />
<span>Enregistrer les modifications</span>
</>
)}
</button>
</div>
</form>
</div>
</>
);
}
export default EditUserModal;
+414 -348
View File
@@ -1,419 +1,485 @@
.navbar {
/* ============================================================
Tokens
============================================================ */
:root {
--topbar-h: 60px;
--sidebar-w: 260px;
--bg: #09090b;
--surface: #111115;
--surface-2: #1c1c22;
--border: rgba(255, 255, 255, 0.07);
--primary: #8b5cf6;
--primary-soft: rgba(139, 92, 246, 0.12);
--primary-glow: rgba(139, 92, 246, 0.25);
--cyan: #22d3ee;
--cyan-soft: rgba(34, 211, 238, 0.1);
--red: #ef4444;
--text: #f4f4f5;
--text-muted: #71717a;
--radius: 10px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Body offset
============================================================ */
body {
padding-top: var(--topbar-h);
}
/* ============================================================
Top Bar
============================================================ */
.topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background-color: #1a1a1a;
border-bottom: 2px solid #333;
height: var(--topbar-h);
z-index: 900;
display: flex;
align-items: center;
padding: 0 1rem;
z-index: 100;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
padding: 0 0.75rem;
gap: 0.5rem;
background: rgba(9, 9, 11, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 24px rgba(0, 0, 0, 0.4);
}
.hamburger-menu {
.topbar-toggle {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
}
.topbar-toggle:hover,
.topbar-toggle.is-open {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.topbar-brand {
flex: 1;
text-align: center;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.02em;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
user-select: none;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* ── Icon button (notif / cart) ── */
.topbar-icon-btn {
position: relative;
width: 40px;
height: 40px;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 8px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.3s;
}
.hamburger-menu:active {
transform: scale(0.9);
}
.hamburger-line {
width: 100%;
height: 3px;
background-color: white;
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger-line.open:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px);
}
.hamburger-line.open:nth-child(2) {
opacity: 0;
}
.hamburger-line.open:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.navbar-title {
color: white;
font-size: clamp(1.2rem, 4vw, 1.5rem);
margin: 0 0 0 1rem;
font-weight: 600;
flex: 1;
}
.cart-button {
position: relative;
background: transparent;
border: none;
width: 45px;
height: 45px;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
transition: all 0.2s;
margin-left: 330px;
}
.cart-button:active {
transform: scale(0.9);
.topbar-icon-btn:hover {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.cart-icon {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
.topbar-icon-btn:active {
transform: scale(0.93);
}
.cart-count {
.topbar-badge {
position: absolute;
top: 2px;
right: 2px;
background-color: #ff0000;
color: white;
font-size: 0.7rem;
font-weight: bold;
min-width: 18px;
height: 18px;
border-radius: 50%;
top: 4px;
right: 4px;
min-width: 17px;
height: 17px;
border-radius: 999px;
background: var(--red);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
padding: 0 3px;
border: 2px solid var(--bg);
line-height: 1;
}
/* Menu latéral */
.side-menu {
/* ============================================================
Overlay
============================================================ */
.sidebar-overlay {
position: fixed;
inset: 0;
z-index: 950;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ============================================================
Sidebar
============================================================ */
.sidebar {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
background-color: #0a0a0a;
border-right: 2px solid #333;
z-index: 200;
transition: left 0.3s ease;
overflow-y: auto;
left: 0;
width: var(--sidebar-w);
height: 100dvh;
z-index: 1000;
display: flex;
flex-direction: column;
background: var(--bg);
border-right: 1px solid var(--border);
transform: translateX(-100%);
transition: transform var(--transition);
overflow: hidden;
}
.side-menu.open {
left: 0;
.sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.6);
}
.side-menu-header {
/* ── Header ── */
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1rem;
border-bottom: 2px solid #333;
justify-content: space-between;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.side-menu-header h3 {
color: white;
margin: 0;
font-size: 1.5rem;
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.close-menu {
background: transparent;
border: none;
color: white;
font-size: 2.5rem;
cursor: pointer;
width: 40px;
height: 40px;
.sidebar-brand-icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--primary-soft);
border: 1px solid rgba(139, 92, 246, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-size: 1rem;
flex-shrink: 0;
}
.sidebar-brand-name {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--text);
letter-spacing: 0.01em;
}
.sidebar-brand-sub {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 1px;
}
.sidebar-close {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
transition: all 0.2s;
flex-shrink: 0;
}
.close-menu:active {
transform: scale(0.9);
.sidebar-close:hover {
background: var(--surface-2);
color: var(--text);
}
.side-menu-nav {
/* ── Nav ── */
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0.75rem;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
padding: 1rem 0;
flex: 1;
gap: 2px;
}
.menu-item {
color: white;
text-decoration: none;
padding: 1rem 1.5rem;
font-size: 1.1rem;
border-bottom: 1px solid #222;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.menu-item:active {
background-color: #1a1a1a;
transform: translateX(5px);
}
/* Overlay */
.menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 150;
}
/* Désactiver hover sur tactile */
@media (hover: none) {
.menu-item:hover {
background-color: transparent;
}
}
/* Sélecteur de couleur */
.color-picker-section {
margin-top: 0;
border-top: 2px solid #333;
padding: 1rem;
background-color: #0a0a0a;
}
.color-picker-toggle {
width: 100%;
background-color: #1a1a1a;
border: 2px solid #333;
border-radius: 8px;
padding: 0.8rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.color-picker-toggle:active {
transform: scale(0.98);
background-color: #222;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.color-picker-toggle span {
color: white;
font-size: 1rem;
flex: 1;
}
.arrow {
color: white;
font-size: 0.8rem;
transition: transform 0.3s;
}
.arrow.up {
transform: rotate(180deg);
}
.color-options {
margin-top: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.color-option {
background-color: #0a0a0a;
border: 1px solid #333;
border-radius: 8px;
padding: 0.8rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.color-option:active {
transform: scale(0.98);
background-color: #1a1a1a;
}
.color-option.active {
border-color: #4ade80;
background-color: #1a2a1a;
}
.color-circle {
width: 25px;
height: 25px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.color-option span {
color: white;
font-size: 0.95rem;
flex: 1;
}
.check {
color: #4ade80;
font-size: 1.2rem;
font-weight: bold;
}
/* Bouton panier flottant */
.cart-button-float {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1001;
width: 56px;
height: 56px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.2),
rgba(109, 40, 217, 0.1)
);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 50%;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.cart-button-float:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.3),
rgba(109, 40, 217, 0.15)
);
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.cart-button-float:active {
transform: scale(0.95);
}
.cart-icon-fa {
font-size: 1.5rem;
}
.cart-count {
position: absolute;
top: -4px;
right: -4px;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
min-width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
border: 2px solid #0f0f0f;
}
/* Bouton Telegram */
.telegram-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.1),
rgba(32, 139, 220, 0.05)
);
border: 1px solid rgba(37, 161, 244, 0.3);
border-radius: 12px;
color: #25a1f4;
font-size: 0.95rem;
font-weight: 600;
gap: 0.85rem;
padding: 0.7rem 0.9rem;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 0.5rem;
text-align: left;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
position: relative;
}
.telegram-button:hover:not(:disabled) {
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.15),
rgba(32, 139, 220, 0.08)
);
border-color: rgba(37, 161, 244, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 161, 244, 0.3);
.menu-item:hover:not(:disabled) {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
.telegram-button:active:not(:disabled) {
.menu-item.active {
background: var(--primary-soft);
border-color: rgba(139, 92, 246, 0.2);
color: var(--primary);
}
.menu-item:active:not(:disabled) {
transform: scale(0.98);
}
.telegram-button:disabled {
opacity: 0.5;
.menu-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Footer styles */
.menu-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--surface);
font-size: 0.85rem;
flex-shrink: 0;
transition: all var(--transition);
}
.menu-item.active .menu-icon {
background: rgba(139, 92, 246, 0.2);
color: var(--primary);
}
.menu-label {
flex: 1;
}
.menu-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
flex-shrink: 0;
box-shadow: 0 0 8px var(--primary-glow);
}
/* ── Footer ── */
.sidebar-footer {
margin-top: auto;
padding: 1rem;
border-top: 1px solid #333;
padding: 0.75rem;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
}
.sidebar-footer-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.7rem 1rem;
border-radius: var(--radius);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
}
.sidebar-footer-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sidebar-footer-btn.telegram {
background: var(--cyan-soft);
border: 1px solid rgba(34, 211, 238, 0.2);
color: var(--cyan);
}
.sidebar-footer-btn.telegram:hover:not(:disabled) {
background: rgba(34, 211, 238, 0.15);
border-color: rgba(34, 211, 238, 0.35);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(34, 211, 238, 0.15);
}
.sidebar-footer-btn.logout {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.18);
color: #f87171;
}
.sidebar-footer-btn.logout:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.3);
transform: translateY(-1px);
}
.sidebar-footer-btn:active:not(:disabled) {
transform: scale(0.97);
}
/* ============================================================
Notification panel
============================================================ */
.notif-panel {
position: absolute;
top: calc(var(--topbar-h) - 4px);
right: 0;
width: 300px;
max-height: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
animation: slideDown 0.18s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.notif-panel-header {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.notif-empty {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
max-height: 320px;
}
.notif-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
}
.notif-item:last-child { border-bottom: none; }
.notif-unread {
background: var(--primary-soft);
border-left: 3px solid var(--primary);
}
.notif-read { opacity: 0.55; }
.notif-message {
color: var(--text);
font-size: 0.83rem;
line-height: 1.45;
}
.notif-time {
color: var(--text-muted);
font-size: 0.72rem;
}
/* ============================================================
Responsive
============================================================ */
@media (hover: none) {
.menu-item:hover { background: transparent; border-color: transparent; color: var(--text-muted); }
.menu-item.active:hover { background: var(--primary-soft); color: var(--primary); }
.topbar-toggle:hover { background: transparent; border-color: transparent; }
.topbar-icon-btn:hover { background: transparent; border-color: transparent; }
}
+174 -135
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useCart } from "../context/CartContext";
import "./Navbar.css";
@@ -12,10 +12,12 @@ import {
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBell,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { getClientNotifications, markNotificationsRead } from "../api/api";
import type { ClientNotification } from "../api/api";
interface MenuItem {
id: string;
@@ -27,98 +29,98 @@ interface MenuItem {
function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
const [notifications, setNotifications] = useState<ClientNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showNotifPanel, setShowNotifPanel] = useState(false);
const seenKeysRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(null);
const { cartCount } = useCart();
const navigate = useNavigate();
const location = useLocation();
const fetchNotifications = useCallback(async () => {
const res = await getClientNotifications();
if (!res.success) return;
setNotifications(res.notifications);
setUnreadCount(res.unread_count);
if (!isFirstLoadRef.current) {
for (const n of res.notifications) {
if (n.read) continue;
const key = `${n.command_id}-${n.type}-${n.created_at}`;
if (!seenKeysRef.current.has(key)) {
seenKeysRef.current.add(key);
}
}
} else {
for (const n of res.notifications) {
seenKeysRef.current.add(`${n.command_id}-${n.type}-${n.created_at}`);
}
isFirstLoadRef.current = false;
}
}, []);
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
return () => clearInterval(interval);
}, [fetchNotifications]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (notifPanelRef.current && !notifPanelRef.current.contains(e.target as Node)) {
setShowNotifPanel(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleNotifBellClick = async () => {
setShowNotifPanel((prev) => !prev);
if (!showNotifPanel && unreadCount > 0) {
await markNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
};
const menuItems: MenuItem[] = [
{
id: "accueil",
label: "Accueil",
icon: faHome,
path: "/user/accueil",
},
{
id: "produits",
label: "Nos Produits",
icon: faBox,
path: "/user/nos-produits",
},
{
id: "panier",
label: "Passer Commande",
icon: faShoppingCart,
path: "/user/panier",
},
{
id: "suivi",
label: "Suivi Livraison",
icon: faTruck,
path: "/user/suivi-livraison",
},
{
id: "historique",
label: "Historique",
icon: faClockRotateLeft,
path: "/user/consultation-historique",
},
{ id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" },
{ id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" },
{ id: "panier", label: "Mon Panier", icon: faShoppingCart, path: "/user/panier" },
{ id: "suivi", label: "Suivi Livraison", icon: faTruck, path: "/user/suivi-livraison" },
{ id: "historique", label: "Historique", icon: faClockRotateLeft, path: "/user/consultation-historique" },
];
const toggleMenu = (): void => {
setIsMenuOpen(!isMenuOpen);
};
const toggleMenu = () => setIsMenuOpen((v) => !v);
const closeMenu = () => setIsMenuOpen(false);
const closeMenu = (): void => {
setIsMenuOpen(false);
};
const handleNavigation = (path: string): void => {
const handleNavigation = (path: string) => {
navigate(path);
closeMenu();
};
const handleLogout = async (): Promise<void> => {
const handleLogout = async () => {
if (isLoggingOut) return;
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
await fetch("/api/v2/admin/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
});
} catch (_) {}
}
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login/client", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
} catch (_) {
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login/client", { replace: true });
@@ -127,86 +129,125 @@ function Navbar() {
}
};
const handleTelegram = (): void => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const handleTelegram = () => window.open("https://t.me/milieu_nantais", "_blank");
return (
<>
{/* Bouton Toggle */}
<button
className={`sidebar-toggle ${isMenuOpen ? "sidebar-open" : ""}`}
onClick={toggleMenu}
aria-label="Toggle menu"
>
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button>
{/* ── Top Bar ─────────────────────────────────── */}
<header className="topbar">
<button
className={`topbar-toggle ${isMenuOpen ? "is-open" : ""}`}
onClick={toggleMenu}
aria-label="Menu"
>
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isMenuOpen && (
<div className="sidebar-overlay" onClick={closeMenu} />
)}
<span className="topbar-brand">MilieuNantais</span>
{/* Bouton Panier (flottant en haut à droite) */}
<button
className="cart-button-float"
onClick={() => navigate("/user/panier")}
aria-label="Panier"
>
<FontAwesomeIcon
icon={faShoppingCart}
className="cart-icon-fa"
/>
{cartCount > 0 && (
<span className="cart-count">{cartCount}</span>
)}
</button>
<div className="topbar-actions">
{/* Notifications */}
<div className="notif-wrapper" ref={notifPanelRef}>
<button
className="topbar-icon-btn"
onClick={handleNotifBellClick}
aria-label="Notifications"
>
<FontAwesomeIcon icon={faBell} />
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Sidebar */}
{showNotifPanel && (
<div className="notif-panel">
<div className="notif-panel-header">Notifications</div>
{notifications.length === 0 ? (
<div className="notif-empty">Aucune notification</div>
) : (
<ul className="notif-list">
{notifications.map((n, i) => (
<li
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">
{new Date(n.created_at).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Panier */}
<button
className="topbar-icon-btn"
onClick={() => navigate("/user/panier")}
aria-label="Panier"
>
<FontAwesomeIcon icon={faShoppingCart} />
{cartCount > 0 && (
<span className="topbar-badge">{cartCount}</span>
)}
</button>
</div>
</header>
{/* ── Overlay ─────────────────────────────────── */}
{isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />}
{/* ── Sidebar ─────────────────────────────────── */}
<aside className={`sidebar ${isMenuOpen ? "open" : ""}`}>
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faShoppingCart} size="lg" />
<div className="sidebar-brand">
<div className="sidebar-brand-icon">
<FontAwesomeIcon icon={faShoppingCart} />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>User Panel</p>
<div>
<p className="sidebar-brand-name">Milieu-Nantais</p>
<p className="sidebar-brand-sub">Mon espace</p>
</div>
</div>
<button className="sidebar-close" onClick={closeMenu} aria-label="Fermer">
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.id}>
<button
className={`menu-item ${isActive ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">{item.label}</span>
{isActive && <span className="menu-dot" />}
</button>
</li>
);
})}
</ul>
</nav>
{/* Footer avec boutons Telegram et déconnexion */}
<div className="sidebar-footer">
<button
className="telegram-button"
className="sidebar-footer-btn telegram"
onClick={handleTelegram}
disabled={isLoggingOut}
>
@@ -214,14 +255,12 @@ function Navbar() {
<span>Telegram</span>
</button>
<button
className="logout-button"
className="sidebar-footer-btn logout"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
<span>{isLoggingOut ? "Déconnexion…" : "Déconnexion"}</span>
</button>
</div>
</aside>
@@ -1,340 +0,0 @@
// ============================================
// components/admin/ProductCreateModal.tsx - VERSION AVEC BOUTONS
// ============================================
// ✅ Boutons stylisés au lieu de select natif (comme AdminUsers)
// ✅ Pas de hover gris natif !
import React, { useState } from 'react';
import { X, Plus, Trash2, Upload, DollarSign } from 'lucide-react';
import { createProductAdmin } from '../api/api_admin';
import type { CreateProductData, ProductPrice} from '../api/api_admin_types';
import './ProductModal.css';
interface ProductCreateModalProps {
onClose: () => void;
onSuccess: () => void;
}
const ProductCreateModal: React.FC<ProductCreateModalProps> = ({ onClose, onSuccess }) => {
// ============================================
// 📝 STATE
// ============================================
const [name, setName] = useState('');
const [category, setCategory] = useState('weed&hash');
const [description, setDescription] = useState('');
const [stock, setStock] = useState<number>(0);
const [prices, setPrices] = useState<ProductPrice[]>([
{ quantity: 1, price: 0 }
]);
const [mediaFiles, setMediaFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ✅ Options de catégories
const categoryOptions = [
{ value: 'weed&hash', label: 'Weed & Hash' },
{ value: 'zipette&co', label: 'Zipette & Co' },
{ value: 'gros&semi', label: 'Gros & Semi' }
];
// ============================================
// 💰 GESTION DES PRIX
// ============================================
const addPriceRow = () => {
setPrices([...prices, { quantity: 1, price: 0 }]);
};
const removePriceRow = (index: number) => {
if (prices.length > 1) {
setPrices(prices.filter((_, i) => i !== index));
}
};
const updatePrice = (index: number, field: 'quantity' | 'price', value: number) => {
const newPrices = [...prices];
newPrices[index][field] = value;
setPrices(newPrices);
};
// ============================================
// 📁 GESTION DES FICHIERS
// ============================================
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
setMediaFiles([...mediaFiles, ...newFiles]);
console.log(`📁 ${newFiles.length} fichier(s) ajouté(s)`);
}
};
const removeFile = (index: number) => {
setMediaFiles(mediaFiles.filter((_, i) => i !== index));
};
// ============================================
// ✅ VALIDATION
// ============================================
const validateForm = (): string | null => {
if (!name.trim()) return 'Le nom est requis';
if (!description.trim()) return 'La description est requise';
if (stock < 0) return 'Le stock ne peut pas être négatif';
if (prices.length === 0) return 'Au moins un prix est requis';
for (const price of prices) {
if (price.quantity <= 0) return 'Toutes les quantités doivent être positives';
if (price.price <= 0) return 'Tous les prix doivent être positifs';
}
return null;
};
// ============================================
// 📤 SOUMISSION
// ============================================
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
try {
setLoading(true);
setError(null);
console.log('📤 [CREATE_MODAL] Envoi du formulaire...');
const productData: CreateProductData = {
name: name.trim(),
category,
description: description.trim(),
stock,
prices,
};
const response = await createProductAdmin(productData, mediaFiles);
if (response.success) {
console.log('✅ [CREATE_MODAL] Produit créé avec succès');
onSuccess();
} else {
setError(response.error || 'Erreur lors de la création du produit');
}
} catch (error) {
console.error('❌ [CREATE_MODAL] Erreur:', error);
setError(error instanceof Error ? error.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal">
{/* Header */}
<div className="modal-header">
<h2>Créer un Nouveau Produit</h2>
<button className="close-modal" onClick={onClose}>
<X size={24} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="modal-content">
{error && (
<div className="error-message">
<p>{error}</p>
</div>
)}
{/* Informations de base */}
<div className="form-section">
<h3>Informations de Base</h3>
<div className="form-group">
<label htmlFor="name">Nom du Produit *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: OG Kush Premium"
required
/>
</div>
{/* ✅ CATÉGORIE AVEC BOUTONS AU LIEU DE SELECT */}
<div className="form-group">
<label>Catégorie *</label>
<div className="filter-buttons">
{categoryOptions.map(option => (
<button
key={option.value}
type="button"
className={category === option.value ? 'active' : ''}
onClick={() => setCategory(option.value)}
disabled={loading}
>
{option.label}
</button>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez le produit en détail..."
rows={4}
required
/>
</div>
<div className="form-group">
<label htmlFor="stock">Stock (en grammes) *</label>
<input
id="stock"
type="number"
min="0"
step="0.1"
value={stock}
onChange={(e) => setStock(parseFloat(e.target.value))}
placeholder="100"
required
/>
</div>
</div>
{/* Prix */}
<div className="form-section">
<div className="section-header">
<h3>Tarifs</h3>
<button type="button" className="add-price-btn" onClick={addPriceRow}>
<Plus size={18} />
Ajouter un Prix
</button>
</div>
<div className="prices-grid">
{prices.map((price, index) => (
<div key={index} className="price-row">
<div className="price-input-group">
<label>Quantité (g)</label>
<input
type="number"
min="1"
step="1"
value={price.quantity}
onChange={(e) => updatePrice(index, 'quantity', parseInt(e.target.value))}
required
/>
</div>
<div className="price-input-group">
<label>Prix ()</label>
<div className="price-input-wrapper">
<DollarSign size={18} />
<input
type="number"
min="0"
step="0.01"
value={price.price}
onChange={(e) => updatePrice(index, 'price', parseFloat(e.target.value))}
required
/>
</div>
</div>
{prices.length > 1 && (
<button
type="button"
className="remove-price-btn"
onClick={() => removePriceRow(index)}
title="Supprimer ce prix"
>
<Trash2 size={18} />
</button>
)}
</div>
))}
</div>
</div>
{/* Médias */}
<div className="form-section">
<h3>Médias (Images & Vidéos)</h3>
<div className="upload-zone">
<label htmlFor="media-upload" className="upload-label">
<Upload size={32} />
<p>Cliquez pour ajouter des fichiers</p>
<span>Images (JPG, PNG, WebP) ou Vidéos (MP4, WebM)</span>
</label>
<input
id="media-upload"
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
{mediaFiles.length > 0 && (
<div className="files-list">
{mediaFiles.map((file, index) => (
<div key={index} className="file-item">
<div className="file-info">
<span className="file-name">{file.name}</span>
<span className="file-size">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>
<button
type="button"
className="remove-file-btn"
onClick={() => removeFile(index)}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</form>
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
onClick={handleSubmit}
disabled={loading}
>
{loading ? 'Création...' : 'Créer le Produit'}
</button>
</div>
</div>
</>
);
};
export default ProductCreateModal;
@@ -18,7 +18,7 @@ import {
Video as VideoIcon,
Play,
} from "lucide-react";
import type { Product } from "../api/api_admin_types"; // ✅ CORRIGÉ
import type { Product, ProductPrice } from "../api/api_types";
import "./ProductModal.css";
interface ProductDetailsModalProps {
@@ -169,7 +169,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
{/* Miniatures */}
{product.media && product.media.length > 1 && (
<div className="media-thumbnails">
{product.media.map((media, index) => (
{product.media.map((media: { url: string; type: string }, index: number) => (
<button
key={index}
className={`thumbnail ${index === currentMediaIndex ? "active" : ""}`}
@@ -247,7 +247,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
</h3>
<div className="prices-table">
{product.prices && product.prices.length > 0 ? (
product.prices.map((price, index) => (
product.prices.map((price: ProductPrice, index: number) => (
<div
key={index}
className="price-row-display"
@@ -286,12 +286,12 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
<span>
{
product.media.filter(
(m) => m.type === "image",
(m: { url: string; type: string }) => m.type === "image",
).length
}{" "}
Image
{product.media.filter(
(m) => m.type === "image",
(m: { url: string; type: string }) => m.type === "image",
).length > 1
? "s"
: ""}
@@ -302,12 +302,12 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
<span>
{
product.media.filter(
(m) => m.type === "video",
(m: { url: string; type: string }) => m.type === "video",
).length
}{" "}
Vidéo
{product.media.filter(
(m) => m.type === "video",
(m: { url: string; type: string }) => m.type === "video",
).length > 1
? "s"
: ""}
@@ -1,550 +0,0 @@
// ============================================
// components/admin/ProductEditModal.tsx
// ============================================
// ✅ Gestion correcte de la mise à jour des produits
// ✅ Envoi en JSON (pas FormData)
// ✅ Upload séparé des médias
// ✅ Boutons au lieu de select (comme AdminUsers)
import React, { useState } from "react";
import {
X,
Plus,
Trash2,
Upload,
DollarSign,
Image as ImageIcon,
} from "lucide-react";
import {
updateProductAdmin,
deleteProductMediaAdmin,
uploadProductMediaAdmin,
} from "../api/api_admin";
import type { Product, ProductPrice } from "../api/api_admin_types";
import "./ProductModal.css";
interface ProductEditModalProps {
product: Product;
onClose: () => void;
onSuccess: () => void;
}
const ProductEditModal: React.FC<ProductEditModalProps> = ({
product,
onClose,
onSuccess,
}) => {
// ============================================
// 📝 STATE
// ============================================
const [name, setName] = useState(product.name);
const [category, setCategory] = useState(product.category);
const [description, setDescription] = useState(product.description);
const [stock, setStock] = useState(product.stock);
const [prices, setPrices] = useState<ProductPrice[]>(
product.prices && product.prices.length > 0
? product.prices
: [{ quantity: 1, price: 0 }],
);
const [existingMedia, setExistingMedia] = useState(product.media || []);
const [newMediaFiles, setNewMediaFiles] = useState<File[]>([]);
const [mediaToDelete, setMediaToDelete] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ✅ Options de catégories
const categoryOptions = [
{ value: "weed&hash", label: "Weed & Hash" },
{ value: "zipette&co", label: "Zipette & Co" },
{ value: "gros&semi", label: "Gros & Semi" },
];
// ============================================
// 💰 GESTION DES PRIX
// ============================================
const addPriceRow = () => {
setPrices([...prices, { quantity: 1, price: 0 }]);
};
const removePriceRow = (index: number) => {
if (prices.length > 1) {
setPrices(prices.filter((_, i) => i !== index));
}
};
const updatePrice = (
index: number,
field: "quantity" | "price",
value: number,
) => {
const newPrices = [...prices];
newPrices[index][field] = value;
setPrices(newPrices);
};
// ============================================
// 📁 GESTION DES MÉDIAS
// ============================================
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
setNewMediaFiles([...newMediaFiles, ...newFiles]);
console.log(
`📁 ${newFiles.length} nouveau(x) fichier(s) ajouté(s)`,
);
}
};
const removeNewFile = (index: number) => {
setNewMediaFiles(newMediaFiles.filter((_, i) => i !== index));
};
const markMediaForDeletion = (mediaId: number) => {
setMediaToDelete([...mediaToDelete, mediaId]);
setExistingMedia(existingMedia.filter((m) => m.id !== mediaId));
console.log(`🗑️ Média ${mediaId} marqué pour suppression`);
};
// ============================================
// ✅ VALIDATION
// ============================================
const validateForm = (): string | null => {
if (!name.trim()) return "Le nom est requis";
if (!description.trim()) return "La description est requise";
if (stock < 0) return "Le stock ne peut pas être négatif";
if (prices.length === 0) return "Au moins un prix est requis";
for (const price of prices) {
if (price.quantity <= 0)
return "Toutes les quantités doivent être positives";
if (price.price <= 0) return "Tous les prix doivent être positifs";
}
return null;
};
// ============================================
// 📤 SOUMISSION - VERSION CORRIGÉE
// ============================================
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
if (!product.id) {
setError("ID du produit manquant");
return;
}
try {
setLoading(true);
setError(null);
console.log(
"📤 [EDIT_MODAL] Début de la mise à jour du produit:",
product.id,
);
// ============================================
// ÉTAPE 1: Supprimer les médias marqués
// ============================================
if (mediaToDelete.length > 0) {
console.log(
`🗑️ Suppression de ${mediaToDelete.length} média(s)...`,
);
for (const mediaId of mediaToDelete) {
try {
await deleteProductMediaAdmin(product.id, mediaId);
console.log(`✅ Média ${mediaId} supprimé`);
} catch (err) {
console.warn(
`⚠️ Erreur suppression média ${mediaId}:`,
err,
);
}
}
}
// ============================================
// ÉTAPE 2: Préparer les données de mise à jour (JSON)
// ============================================
const updateData: Partial<Product> = {
name: name.trim(),
category,
description: description.trim(),
stock,
prices: prices.map((p) => ({
quantity: p.quantity,
price: p.price,
})),
};
console.log("📝 [EDIT_MODAL] Données de mise à jour:", updateData);
// ============================================
// ÉTAPE 3: Mettre à jour le produit
// ============================================
const response = await updateProductAdmin(product.id, updateData);
if (!response.success) {
console.error("❌ [EDIT_MODAL] Erreur API:", response.error);
setError(
response.error ||
"Erreur lors de la mise à jour du produit",
);
setLoading(false);
return;
}
console.log("✅ [EDIT_MODAL] Produit mis à jour avec succès");
// ============================================
// ÉTAPE 4: Upload des nouveaux médias
// ============================================
if (newMediaFiles.length > 0) {
console.log(
`📤 [EDIT_MODAL] Upload de ${newMediaFiles.length} nouveau(x) média(s)...`,
);
for (const file of newMediaFiles) {
try {
const fileType = file.type.startsWith("image/")
? "image"
: "video";
const uploadResponse = await uploadProductMediaAdmin(
product.id,
file,
fileType,
);
if (uploadResponse.success) {
console.log(`✅ Média uploadé: ${file.name}`);
} else {
console.warn(
`⚠️ Erreur upload ${file.name}:`,
uploadResponse.error,
);
}
} catch (err) {
console.warn(`⚠️ Erreur upload ${file.name}:`, err);
}
}
}
console.log("🎉 [EDIT_MODAL] Toutes les modifications appliquées");
// Fermer et rafraîchir
onSuccess();
} catch (error) {
console.error("❌ [EDIT_MODAL] Erreur:", error);
setError(
error instanceof Error ? error.message : "Erreur inconnue",
);
} finally {
setLoading(false);
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal edit-modal">
{/* Header */}
<div className="modal-header">
<h2>Modifier le Produit</h2>
<button
className="close-modal"
onClick={onClose}
disabled={loading}
>
<X size={24} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="modal-content">
{error && (
<div className="error-message">
<p>{error}</p>
</div>
)}
{/* Informations de base */}
<div className="form-section">
<h3>Informations de Base</h3>
<div className="form-group">
<label htmlFor="name">Nom du Produit *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: OG Kush Premium"
disabled={loading}
required
/>
</div>
{/* ✅ CATÉGORIE AVEC BOUTONS AU LIEU DE SELECT */}
<div className="form-group">
<label>Catégorie *</label>
<div className="filter-buttons">
{categoryOptions.map((option) => (
<button
key={option.value}
type="button"
className={
category === option.value
? "active"
: ""
}
onClick={() =>
setCategory(option.value)
}
disabled={loading}
>
{option.label}
</button>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez le produit en détail..."
rows={4}
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="stock">Stock (en grammes) *</label>
<input
id="stock"
type="number"
min="0"
step="0.1"
value={stock}
onChange={(e) =>
setStock(parseFloat(e.target.value) || 0)
}
placeholder="100"
disabled={loading}
required
/>
</div>
</div>
{/* Prix */}
<div className="form-section">
<div className="section-header">
<h3>Tarifs</h3>
<button
type="button"
className="add-price-btn"
onClick={addPriceRow}
disabled={loading}
>
<Plus size={18} />
Ajouter un Prix
</button>
</div>
<div className="prices-grid">
{prices.map((price, index) => (
<div key={index} className="price-row">
<div className="price-input-group">
<label>Quantité (g)</label>
<input
type="number"
min="1"
step="1"
value={price.quantity}
onChange={(e) =>
updatePrice(
index,
"quantity",
parseInt(e.target.value) ||
1,
)
}
disabled={loading}
required
/>
</div>
<div className="price-input-group">
<label>Prix ()</label>
<div className="price-input-wrapper">
<DollarSign size={18} />
<input
type="number"
min="0"
step="0.01"
value={price.price}
onChange={(e) =>
updatePrice(
index,
"price",
parseFloat(
e.target.value,
) || 0,
)
}
disabled={loading}
required
/>
</div>
</div>
{prices.length > 1 && (
<button
type="button"
className="remove-price-btn"
onClick={() =>
removePriceRow(index)
}
disabled={loading}
title="Supprimer ce prix"
>
<Trash2 size={18} />
</button>
)}
</div>
))}
</div>
</div>
{/* Médias existants */}
{existingMedia.length > 0 && (
<div className="form-section">
<h3>Médias Actuels</h3>
<div className="existing-media-grid">
{existingMedia.map((media) => (
<div
key={media.id}
className="existing-media-item"
>
{media.type === "image" ? (
<img
src={`${media.url}`}
alt="Media"
className="media-thumbnail"
/>
) : (
<div className="video-thumbnail">
<ImageIcon size={32} />
<span>Vidéo</span>
</div>
)}
<button
type="button"
className="delete-media-btn"
onClick={() =>
markMediaForDeletion(media.id!)
}
disabled={loading}
title="Supprimer ce média"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
)}
{/* Nouveaux médias */}
<div className="form-section">
<h3>Ajouter de Nouveaux Médias</h3>
<div className="upload-zone">
<label
htmlFor="media-upload"
className="upload-label"
>
<Upload size={32} />
<p>Cliquez pour ajouter des fichiers</p>
<span>
Images (JPG, PNG, WebP) ou Vidéos (MP4,
WebM)
</span>
</label>
<input
id="media-upload"
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileSelect}
disabled={loading}
style={{ display: "none" }}
/>
</div>
{newMediaFiles.length > 0 && (
<div className="files-list">
{newMediaFiles.map((file, index) => (
<div key={index} className="file-item">
<div className="file-info">
<span className="file-name">
{file.name}
</span>
<span className="file-size">
{(file.size / 1024).toFixed(1)}{" "}
KB
</span>
</div>
<button
type="button"
className="remove-file-btn"
onClick={() => removeNewFile(index)}
disabled={loading}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</form>
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
onClick={handleSubmit}
disabled={loading}
>
{loading ? "Mise à jour..." : "Mettre à Jour"}
</button>
</div>
</div>
</>
);
};
export default ProductEditModal;
-477
View File
@@ -1,477 +0,0 @@
/* Sidebar Toggle Button */
.sidebar-toggle {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1001;
width: 48px;
height: 48px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.2),
rgba(109, 40, 217, 0.1)
);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
.sidebar-toggle:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.3),
rgba(109, 40, 217, 0.15)
);
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
/* Overlay */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999;
backdrop-filter: blur(4px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Sidebar Container */
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
background: linear-gradient(180deg, #0f0f0f 0%, #1a1a1a 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.5);
}
/* Sidebar Header */
.sidebar-header {
padding: 2rem 1.5rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
flex-shrink: 0;
}
.logo-text h2 {
color: white;
font-size: 1.3rem;
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.logo-text p {
color: #888;
font-size: 0.75rem;
margin: 0.2rem 0 0 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Navigation */
.sidebar-nav {
flex: 1;
padding: 1.5rem 0;
overflow-y: auto;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-list li {
margin-bottom: 0.5rem;
padding: 0 1rem;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
color: #888;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
text-align: left;
}
.menu-item::before {
content: "";
position: absolute;
left: -100%;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(124, 58, 237, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover::before {
left: 100%;
}
.menu-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-label {
flex: 1;
}
.menu-badge {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 20px;
min-width: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.menu-arrow {
flex-shrink: 0;
opacity: 0;
transform: translateX(-8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.1),
rgba(109, 40, 217, 0.05)
);
border-color: rgba(124, 58, 237, 0.2);
color: white;
transform: translateX(4px);
}
.menu-item:hover .menu-icon {
transform: scale(1.1);
color: #7c3aed;
}
.menu-item:hover .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item.active {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.15),
rgba(109, 40, 217, 0.08)
);
border-color: rgba(124, 58, 237, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.menu-item.active .menu-icon {
color: #7c3aed;
}
.menu-item.active .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item:active {
transform: scale(0.98) translateX(4px);
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: auto;
}
.user-profile {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.01)
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin-bottom: 1rem;
}
.user-avatar {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981, #059669);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 0.95rem;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
color: white;
font-size: 0.95rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
color: #888;
font-size: 0.8rem;
margin: 0.2rem 0 0 0;
}
.logout-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.1),
rgba(220, 38, 38, 0.05)
);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #ef4444;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logout-button:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.15),
rgba(220, 38, 38, 0.08)
);
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.logout-button:active {
transform: scale(0.98);
}
/* Bouton Telegram */
.telegram-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.1),
rgba(32, 139, 220, 0.05)
);
border: 1px solid rgba(37, 161, 244, 0.3);
border-radius: 12px;
color: #25a1f4;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 0.5rem;
}
.telegram-button:hover:not(:disabled) {
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.15),
rgba(32, 139, 220, 0.08)
);
border-color: rgba(37, 161, 244, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 161, 244, 0.3);
}
.telegram-button:active:not(:disabled) {
transform: scale(0.98);
}
.telegram-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 280px;
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
}
@media (max-width: 480px) {
.sidebar {
width: 260px;
}
.sidebar-toggle.sidebar-open {
left: calc(260px + 1rem);
}
.sidebar-header {
padding: 1.5rem 1rem 1rem;
}
.logo-text h2 {
font-size: 1.1rem;
}
.menu-list li {
padding: 0 0.5rem;
}
.menu-item {
padding: 0.8rem 0.8rem;
font-size: 0.9rem;
}
.sidebar-footer {
padding: 1rem;
}
}
@media (hover: none) {
.menu-item:hover {
transform: translateX(0);
}
.menu-item:hover .menu-icon {
transform: none;
}
.sidebar-toggle:hover {
transform: none;
}
.logout-button:hover {
transform: none;
}
}
-232
View File
@@ -1,232 +0,0 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "./Sidebar.css";
// Importation de Font Awesome
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChartLine,
faShoppingCart,
faBox,
faUsers,
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBoxOpen,
faMotorcycle,
faExclamationCircle,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface MenuItem {
id: string;
label: string;
icon: IconDefinition;
path: string;
badge?: number;
}
function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const menuItems: MenuItem[] = [
{
id: "dashboard",
label: "Tableau de Bord",
icon: faChartLine,
path: "/admin/dashboard",
},
{
id: "orders",
label: "Commandes",
icon: faShoppingCart,
path: "/admin/dashboard/orders",
},
{
id: "products",
label: "Produits",
icon: faBox,
path: "/admin/dashboard/products",
},
{
id: "delivery-persons",
label: "Livreurs",
icon: faMotorcycle,
path: "/admin/dashboard/delivery",
},
{
id: "users",
label: "Utilisateurs",
icon: faUsers,
path: "/admin/dashboard/users",
},
{
id: "alerts",
label: "Alertes",
icon: faExclamationCircle,
path: "/admin/dashboard/alerts",
},
];
const handleNavigation = (path: string) => {
navigate(path);
setIsOpen(false);
};
const handleLogout = async () => {
if (isLoggingOut) return; // Éviter les double-clics
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
// ✅ Appel API de logout (via admin API car cabine utilise le même token)
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
}
// ✅ CRITIQUE: Nettoyer sessionStorage
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login-admin/admin", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
// ✅ Même en cas d'erreur, nettoyer et rediriger
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
} finally {
setIsLoggingOut(false);
}
};
const handleTelegram = () => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
return (
<>
{/* Menu Toggle Button */}
<button
className={`sidebar-toggle ${isOpen ? "sidebar-open" : ""}`}
onClick={toggleSidebar}
>
<FontAwesomeIcon icon={isOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isOpen && (
<div
className="sidebar-overlay"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`sidebar ${isOpen ? "open" : ""}`}>
{/* Logo / Brand */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faBoxOpen} size="lg" />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>Admin Panel</p>
</div>
</div>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
{item.badge && (
<span className="menu-badge">
{item.badge}
</span>
)}
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
</ul>
</nav>
{/* User Profile / Logout */}
<div className="sidebar-footer">
<button
className="telegram-button"
onClick={handleTelegram}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faTelegram} />
<span>Telegram</span>
</button>
<button
className="logout-button"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
</button>
</div>
</aside>
</>
);
}
export default Sidebar;
@@ -1,401 +0,0 @@
/* Sidebar Toggle Button */
.sidebar-toggle {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1001;
width: 48px;
height: 48px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
.sidebar-toggle:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.3), rgba(109, 40, 217, 0.15));
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
/* Overlay */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999;
backdrop-filter: blur(4px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Sidebar Container */
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
background: linear-gradient(180deg, #0f0f0f 0%, #1a1a1a 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.5);
}
/* Sidebar Header */
.sidebar-header {
padding: 2rem 1.5rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
flex-shrink: 0;
}
.logo-text h2 {
color: white;
font-size: 1.3rem;
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.logo-text p {
color: #888;
font-size: 0.75rem;
margin: 0.2rem 0 0 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Navigation */
.sidebar-nav {
flex: 1;
padding: 1.5rem 0;
overflow-y: auto;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-list li {
margin-bottom: 0.5rem;
padding: 0 1rem;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
color: #888;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
text-align: left;
}
.menu-item::before {
content: '';
position: absolute;
left: -100%;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(124, 58, 237, 0.1), transparent);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover::before {
left: 100%;
}
.menu-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-label {
flex: 1;
}
.menu-badge {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 20px;
min-width: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.menu-arrow {
flex-shrink: 0;
opacity: 0;
transform: translateX(-8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1), rgba(109, 40, 217, 0.05));
border-color: rgba(124, 58, 237, 0.2);
color: white;
transform: translateX(4px);
}
.menu-item:hover .menu-icon {
transform: scale(1.1);
color: #7c3aed;
}
.menu-item:hover .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item.active {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
border-color: rgba(124, 58, 237, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.menu-item.active .menu-icon {
color: #7c3aed;
}
.menu-item.active .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item:active {
transform: scale(0.98) translateX(4px);
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: auto;
}
.user-profile {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin-bottom: 1rem;
}
.user-avatar {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981, #059669);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 0.95rem;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
color: white;
font-size: 0.95rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
color: #888;
font-size: 0.8rem;
margin: 0.2rem 0 0 0;
}
.logout-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(220, 38, 38, 0.05));
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #ef4444;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logout-button:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.08));
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.logout-button:active {
transform: scale(0.98);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 280px;
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
}
@media (max-width: 480px) {
.sidebar {
width: 260px;
}
.sidebar-toggle.sidebar-open {
left: calc(260px + 1rem);
}
.sidebar-header {
padding: 1.5rem 1rem 1rem;
}
.logo-text h2 {
font-size: 1.1rem;
}
.menu-list li {
padding: 0 0.5rem;
}
.menu-item {
padding: 0.8rem 0.8rem;
font-size: 0.9rem;
}
.sidebar-footer {
padding: 1rem;
}
}
@media (hover: none) {
.menu-item:hover {
transform: translateX(0);
}
.menu-item:hover .menu-icon {
transform: none;
}
.sidebar-toggle:hover {
transform: none;
}
.logout-button:hover {
transform: none;
}
}
@@ -1,229 +0,0 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "./Sidebar.css";
// Importation de Font Awesome
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChartLine,
faShoppingCart,
faUsers,
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBoxOpen,
faMotorcycle,
faExclamationCircle,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface MenuItem {
id: string;
label: string;
icon: IconDefinition;
path: string;
badge?: number;
}
function SidebarCabine() {
const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const menuItems: MenuItem[] = [
{
id: "dashboard",
label: "Tableau de Bord",
icon: faChartLine,
path: "/cabine/dashboard",
},
{
id: "orders",
label: "Commandes",
icon: faShoppingCart,
path: "/cabine/dashboard/orders",
},
{
id: "delivery-persons",
label: "Livreurs",
icon: faMotorcycle,
path: "/cabine/dashboard/delivery",
},
{
id: "users",
label: "Utilisateurs",
icon: faUsers,
path: "/cabine/dashboard/users",
},
{
id: "alerts",
label: "Alertes Livreur",
icon: faExclamationCircle,
path: "/cabine/dashboard/alerts",
},
];
const handleNavigation = (path: string) => {
navigate(path);
setIsOpen(false);
};
/**
* ✅ Fonction de déconnexion complète
*/
const handleLogout = async () => {
if (isLoggingOut) return; // Éviter les double-clics
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
// ✅ Appel API de logout (via admin API car cabine utilise le même token)
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
}
// ✅ CRITIQUE: Nettoyer sessionStorage
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login-cabine/cabine", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
// ✅ Même en cas d'erreur, nettoyer et rediriger
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login", { replace: true });
} finally {
setIsLoggingOut(false);
}
};
const handleTelegram = () => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
return (
<>
{/* Menu Toggle Button */}
<button
className={`sidebar-toggle ${isOpen ? "sidebar-open" : ""}`}
onClick={toggleSidebar}
aria-label="Toggle sidebar"
>
<FontAwesomeIcon icon={isOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isOpen && (
<div
className="sidebar-overlay"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`sidebar ${isOpen ? "open" : ""}`}>
{/* Logo / Brand */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faBoxOpen} size="lg" />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>Cabine Panel</p>
</div>
</div>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
{item.badge && (
<span className="menu-badge">
{item.badge}
</span>
)}
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
</ul>
</nav>
{/* User Profile / Logout */}
<div className="sidebar-footer">
<button
className="telegram-button"
onClick={handleTelegram}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faTelegram} />
<span>Telegram</span>
</button>
<button
className="logout-button"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
</button>
</div>
</aside>
</>
);
}
export default SidebarCabine;
-513
View File
@@ -1,513 +0,0 @@
.tomtom-map-container {
position: relative;
width: 100%;
height: 400px;
border-radius: 16px;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.tomtom-map-container.navigating {
height: 500px;
}
.tomtom-map {
width: 100%;
height: 100%;
}
/* Marqueur du livreur */
.driver-marker {
cursor: pointer;
}
.driver-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
color: white;
animation: pulse-driver 2s infinite;
}
.driver-marker.navigation-mode .driver-marker-inner {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.5);
}
.driver-arrow {
width: 0;
height: 0;
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-bottom: 24px solid white;
}
@keyframes pulse-driver {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 15px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
/* Marqueur de destination */
.destination-marker {
cursor: pointer;
}
.destination-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-radius: 50% 50% 50% 0;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
color: white;
transform: rotate(-45deg);
}
.destination-marker-inner svg {
transform: rotate(45deg);
}
/* Panneau de navigation */
.navigation-panel {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: linear-gradient(
180deg,
rgba(26, 26, 46, 0.98) 0%,
rgba(26, 26, 46, 0.95) 100%
);
backdrop-filter: blur(10px);
border-bottom: 2px solid #10b981;
}
.current-instruction {
display: flex;
align-items: center;
padding: 1rem 1.25rem;
gap: 1rem;
}
.instruction-maneuver {
font-size: 2.5rem;
min-width: 60px;
text-align: center;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.instruction-details {
flex: 1;
}
.instruction-text {
font-size: 1.1rem;
font-weight: 600;
color: white;
line-height: 1.3;
}
.instruction-street {
font-size: 0.9rem;
color: #10b981;
margin-top: 0.25rem;
font-weight: 500;
}
.instruction-distance {
font-size: 1.5rem;
font-weight: 700;
color: #10b981;
min-width: 80px;
text-align: right;
}
.next-instruction {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.05);
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.85rem;
color: #9ca3af;
}
.next-label {
color: #6b7280;
font-weight: 500;
}
.next-maneuver {
font-size: 1.25rem;
}
.next-text {
flex: 1;
}
/* Bouton recentrer */
.recenter-btn {
position: absolute;
bottom: 80px;
right: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(26, 26, 46, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
transition: all 0.2s ease;
}
.recenter-btn:hover {
background: #10b981;
transform: scale(1.1);
}
/* Chargement */
.tomtom-map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
background: rgba(26, 26, 46, 0.95);
padding: 1.5rem 2rem;
border-radius: 12px;
color: white;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #10b981;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Informations de route */
.tomtom-route-info {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
padding: 1rem 1.5rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 5;
}
.route-info-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.route-info-label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.route-info-value {
font-size: 1.25rem;
font-weight: 700;
color: #10b981;
}
.route-info-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
}
/* Bouton afficher instructions */
.show-instructions-btn {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.show-instructions-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* Liste des instructions */
.instructions-list {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 70px;
background: rgba(26, 26, 46, 0.98);
z-index: 15;
display: flex;
flex-direction: column;
border-radius: 16px 16px 0 0;
}
.instructions-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.instructions-header h4 {
margin: 0;
color: white;
font-size: 1rem;
}
.close-instructions-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-instructions-btn:hover {
background: rgba(239, 68, 68, 0.3);
transform: scale(1.1);
}
.instructions-scroll {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.instruction-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s;
}
.instruction-item.active {
background: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10b981;
}
.instruction-item.passed {
opacity: 0.5;
}
.item-maneuver {
font-size: 1.5rem;
min-width: 40px;
text-align: center;
}
.item-details {
flex: 1;
}
.item-text {
color: white;
font-size: 0.95rem;
display: block;
}
.item-street {
color: #10b981;
font-size: 0.8rem;
display: block;
margin-top: 0.25rem;
}
.item-distance {
color: #9ca3af;
font-size: 0.85rem;
min-width: 60px;
text-align: right;
}
/* Erreur */
.tomtom-map-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
text-align: center;
color: white;
}
.tomtom-map-error .error-icon {
color: #f59e0b;
margin-bottom: 1rem;
}
.tomtom-map-error h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #f59e0b;
}
.tomtom-map-error p {
color: #9ca3af;
margin-bottom: 1rem;
}
.tomtom-map-error ol {
text-align: left;
color: #9ca3af;
font-size: 0.9rem;
line-height: 1.8;
}
.tomtom-map-error a {
color: #3b82f6;
text-decoration: underline;
}
.tomtom-map-error code {
background: rgba(59, 130, 246, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: monospace;
color: #60a5fa;
}
/* Toast d'erreur */
.tomtom-map-error-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Responsive */
@media (max-width: 640px) {
.tomtom-map-container {
height: 350px;
border-radius: 12px;
}
.tomtom-map-container.navigating {
height: 450px;
}
.current-instruction {
padding: 0.75rem 1rem;
}
.instruction-maneuver {
font-size: 2rem;
min-width: 50px;
}
.instruction-text {
font-size: 1rem;
}
.instruction-distance {
font-size: 1.25rem;
min-width: 60px;
}
.tomtom-route-info {
flex-wrap: wrap;
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.route-info-value {
font-size: 1rem;
}
.route-info-divider {
height: 30px;
}
.recenter-btn {
bottom: 75px;
width: 40px;
height: 40px;
}
}
-827
View File
@@ -1,827 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import tt from "@tomtom-international/web-sdk-maps";
import "@tomtom-international/web-sdk-maps/dist/maps.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowLeft,
faArrowRight,
faArrowUp,
faArrowTurnUp,
faRotateLeft,
faRotateRight,
faCircleNotch,
faRoad,
faSignOutAlt,
faFlagCheckered,
faCar,
faLocationDot,
faTimes,
faRoute,
faLocationCrosshairs,
} from "@fortawesome/free-solid-svg-icons";
import "./TomTomMap.css";
const TOMTOM_API_KEY = import.meta.env.VITE_TOMTOM_API_KEY;
interface TomTomMapProps {
driverLocation: {
latitude: number;
longitude: number;
} | null;
destinationAddress: string | null;
onRouteCalculated?: (distance: string, duration: string) => void;
onError?: (error: string) => void;
isNavigating?: boolean;
}
interface RouteInfo {
distance: string;
duration: string;
distanceRemaining: number;
timeRemaining: number;
}
interface NavigationInstruction {
instruction: string;
distance: string;
maneuverIcon: any;
streetName?: string;
isActive: boolean;
}
// Mapping des maneuvres vers des icones Font Awesome
const maneuverIcons: Record<string, any> = {
TURN_LEFT: faArrowLeft,
TURN_RIGHT: faArrowRight,
TURN_SLIGHT_LEFT: faArrowTurnUp,
TURN_SLIGHT_RIGHT: faArrowTurnUp,
TURN_SHARP_LEFT: faRotateLeft,
TURN_SHARP_RIGHT: faRotateRight,
KEEP_LEFT: faArrowLeft,
KEEP_RIGHT: faArrowRight,
STRAIGHT: faArrowUp,
ENTER_ROUNDABOUT: faCircleNotch,
EXIT_ROUNDABOUT: faArrowRight,
MOTORWAY_ENTER: faRoad,
MOTORWAY_EXIT: faSignOutAlt,
ARRIVE: faFlagCheckered,
ARRIVE_LEFT: faFlagCheckered,
ARRIVE_RIGHT: faFlagCheckered,
DEPART: faCar,
U_TURN: faRotateLeft,
FOLLOW: faArrowUp,
WAYPOINT_REACHED: faLocationDot,
DEFAULT: faArrowUp,
};
// Mapping des maneuvres vers des textes en francais
const maneuverTranslations: Record<string, string> = {
TURN_LEFT: "Tournez a gauche",
TURN_RIGHT: "Tournez a droite",
TURN_SLIGHT_LEFT: "Tournez legerement a gauche",
TURN_SLIGHT_RIGHT: "Tournez legerement a droite",
TURN_SHARP_LEFT: "Tournez fortement a gauche",
TURN_SHARP_RIGHT: "Tournez fortement a droite",
KEEP_LEFT: "Restez a gauche",
KEEP_RIGHT: "Restez a droite",
STRAIGHT: "Continuez tout droit",
ENTER_ROUNDABOUT: "Entrez dans le rond-point",
EXIT_ROUNDABOUT: "Sortez du rond-point",
MOTORWAY_ENTER: "Entrez sur l'autoroute",
MOTORWAY_EXIT: "Sortez de l'autoroute",
ARRIVE: "Vous etes arrive",
ARRIVE_LEFT: "Destination a gauche",
ARRIVE_RIGHT: "Destination a droite",
DEPART: "Depart",
U_TURN: "Faites demi-tour",
FOLLOW: "Suivez",
WAYPOINT_REACHED: "Point de passage atteint",
};
function TomTomMap({
driverLocation,
destinationAddress,
onRouteCalculated,
onError,
isNavigating = false,
}: TomTomMapProps) {
const mapContainer = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapInstance = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const driverMarker = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const destinationMarker = useRef<any>(null);
const routeLayerId = useRef<string>("route-layer");
const routeCoordinates = useRef<[number, number][]>([]);
const [isMapReady, setIsMapReady] = useState(false);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [destinationCoords, setDestinationCoords] = useState<{
lat: number;
lng: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [instructions, setInstructions] = useState<NavigationInstruction[]>(
[],
);
const [currentInstructionIndex, setCurrentInstructionIndex] = useState(0);
const [showAllInstructions, setShowAllInstructions] = useState(false);
// Calculer la distance entre deux points (formule Haversine)
const calculateDistance = useCallback(
(lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371e3; // Rayon de la Terre en metres
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) *
Math.cos(φ2) *
Math.sin(Δλ / 2) *
Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
},
[],
);
// Formater la distance
const formatDistance = useCallback((meters: number): string => {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}, []);
// Initialiser la carte
useEffect(() => {
if (
!mapContainer.current ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
) {
setError(
"Cle API TomTom non configuree. Veuillez ajouter VITE_TOMTOM_API_KEY dans .env",
);
onError?.("Cle API TomTom non configuree");
return;
}
const map = tt.map({
key: TOMTOM_API_KEY,
container: mapContainer.current,
center: driverLocation
? [driverLocation.longitude, driverLocation.latitude]
: [2.3522, 48.8566],
zoom: 15,
language: "fr-FR",
});
map.addControl(new tt.NavigationControl());
map.on("load", () => {
console.log("TomTom Map chargee");
setIsMapReady(true);
});
mapInstance.current = map;
return () => {
if (mapInstance.current) {
mapInstance.current.remove();
mapInstance.current = null;
}
};
}, []);
// Creer le marqueur du livreur (style navigation)
const createDriverMarkerElement = useCallback(() => {
const el = document.createElement("div");
el.className = "driver-marker navigation-mode";
el.innerHTML = `
<div class="driver-marker-inner">
<div class="driver-arrow"></div>
</div>
`;
return el;
}, []);
// Creer le marqueur de destination
const createDestinationMarkerElement = useCallback(() => {
const el = document.createElement("div");
el.className = "destination-marker";
el.innerHTML = `
<div class="destination-marker-inner">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
`;
return el;
}, []);
// Mettre a jour la position du livreur et centrer la carte en mode navigation
useEffect(() => {
if (!isMapReady || !mapInstance.current || !driverLocation) return;
const { latitude, longitude } = driverLocation;
if (driverMarker.current) {
driverMarker.current.setLngLat([longitude, latitude]);
} else {
driverMarker.current = new tt.Marker({
element: createDriverMarkerElement(),
})
.setLngLat([longitude, latitude])
.addTo(mapInstance.current);
}
// En mode navigation, suivre le livreur
if (isNavigating && destinationCoords) {
mapInstance.current.easeTo({
center: [longitude, latitude],
zoom: 17,
pitch: 60,
bearing: calculateBearing(
latitude,
longitude,
destinationCoords.lat,
destinationCoords.lng,
),
});
// Mettre a jour l'instruction active en fonction de la position
updateCurrentInstruction(latitude, longitude);
} else if (!destinationCoords) {
mapInstance.current.flyTo({
center: [longitude, latitude],
zoom: 15,
});
}
}, [
isMapReady,
driverLocation,
isNavigating,
destinationCoords,
createDriverMarkerElement,
]);
// Calculer l'angle de direction (bearing)
const calculateBearing = (
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number => {
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x =
Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return ((θ * 180) / Math.PI + 360) % 360;
};
// Mettre a jour l'instruction courante basee sur la position
const updateCurrentInstruction = useCallback(
(lat: number, lng: number) => {
if (instructions.length === 0) return;
// Trouver l'instruction la plus proche
let minDistance = Infinity;
let closestIndex = currentInstructionIndex;
// Chercher parmi les instructions restantes
for (
let i = currentInstructionIndex;
i < instructions.length;
i++
) {
// Utiliser les coordonnees de la route pour trouver le point le plus proche
if (routeCoordinates.current.length > 0) {
const segmentStart = Math.floor(
(i / instructions.length) *
routeCoordinates.current.length,
);
if (segmentStart < routeCoordinates.current.length) {
const [pointLng, pointLat] =
routeCoordinates.current[segmentStart];
const dist = calculateDistance(
lat,
lng,
pointLat,
pointLng,
);
if (dist < minDistance) {
minDistance = dist;
closestIndex = i;
}
}
}
}
// Si on est a moins de 50m d'une instruction, passer a la suivante
if (minDistance < 50 && closestIndex > currentInstructionIndex) {
setCurrentInstructionIndex(closestIndex);
// Mettre a jour les instructions pour marquer l'active
setInstructions((prev) =>
prev.map((inst, idx) => ({
...inst,
isActive: idx === closestIndex,
})),
);
}
},
[instructions, currentInstructionIndex, calculateDistance],
);
// Geocoder l'adresse de destination
useEffect(() => {
if (
!destinationAddress ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
)
return;
setIsLoading(true);
setError(null);
// Utiliser l'API REST directement pour le geocodage
const searchUrl = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(destinationAddress)}.json?key=${TOMTOM_API_KEY}&countrySet=FR&limit=1`;
fetch(searchUrl)
.then((response) => response.json())
.then((data) => {
if (data.results && data.results.length > 0) {
const result = data.results[0];
if (
result.position?.lat !== undefined &&
result.position?.lon !== undefined
) {
const coords = {
lat: result.position.lat,
lng: result.position.lon,
};
console.log("Destination geocodee:", coords);
setDestinationCoords(coords);
} else {
setError("Position de destination invalide");
onError?.("Position de destination invalide");
}
} else {
setError("Adresse de destination introuvable");
onError?.("Adresse de destination introuvable");
}
})
.catch((err) => {
console.error("Erreur geocodage:", err);
setError("Erreur lors du geocodage de l'adresse");
onError?.("Erreur lors du geocodage de l'adresse");
})
.finally(() => {
setIsLoading(false);
});
}, [destinationAddress, onError]);
// Ajouter le marqueur de destination et calculer l'itineraire
useEffect(() => {
if (!isMapReady || !mapInstance.current || !destinationCoords) return;
if (destinationMarker.current) {
destinationMarker.current.setLngLat([
destinationCoords.lng,
destinationCoords.lat,
]);
} else {
destinationMarker.current = new tt.Marker({
element: createDestinationMarkerElement(),
})
.setLngLat([destinationCoords.lng, destinationCoords.lat])
.addTo(mapInstance.current);
}
if (driverLocation) {
calculateRoute();
} else {
mapInstance.current.flyTo({
center: [destinationCoords.lng, destinationCoords.lat],
zoom: 15,
});
}
}, [
isMapReady,
destinationCoords,
driverLocation,
createDestinationMarkerElement,
]);
// Calculer l'itineraire avec instructions via API REST directe
const calculateRoute = useCallback(async () => {
if (
!mapInstance.current ||
!driverLocation ||
!destinationCoords ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
)
return;
setIsLoading(true);
try {
// Utiliser l'API REST directement pour avoir les points de la route
const startPoint = `${driverLocation.latitude},${driverLocation.longitude}`;
const endPoint = `${destinationCoords.lat},${destinationCoords.lng}`;
const routeUrl = `https://api.tomtom.com/routing/1/calculateRoute/${startPoint}:${endPoint}/json?key=${TOMTOM_API_KEY}&instructionsType=text&language=fr-FR&traffic=true&travelMode=car`;
console.log("Appel API TomTom Routing...");
const response = await fetch(routeUrl);
const routeData = await response.json();
console.log("Route response:", routeData);
if (!routeData.routes || routeData.routes.length === 0) {
setError("Aucun itineraire trouve");
onError?.("Aucun itineraire trouve");
setIsLoading(false);
return;
}
const route = routeData.routes[0];
let coordinates: [number, number][] = [];
// Extraire les coordonnees des legs
if (route.legs && route.legs.length > 0) {
route.legs.forEach((leg: any) => {
if (leg.points && leg.points.length > 0) {
leg.points.forEach((point: any) => {
coordinates.push([point.longitude, point.latitude]);
});
}
});
}
console.log("Coordonnees extraites:", coordinates.length, "points");
// Fallback: ligne droite si pas de points
if (coordinates.length === 0) {
console.log("Fallback: utilisation ligne droite");
coordinates = [
[driverLocation.longitude, driverLocation.latitude],
[destinationCoords.lng, destinationCoords.lat],
];
}
routeCoordinates.current = coordinates;
// Extraire les instructions de navigation
const navInstructions: NavigationInstruction[] = [];
if (
route.guidance?.instructions &&
route.guidance.instructions.length > 0
) {
route.guidance.instructions.forEach(
(inst: any, index: number) => {
const maneuverKey = inst.maneuver || "STRAIGHT";
const icon =
maneuverIcons[maneuverKey] || maneuverIcons.DEFAULT;
const text =
maneuverTranslations[maneuverKey] || "Continuez";
navInstructions.push({
instruction: inst.message || text,
distance: formatDistance(
inst.routeOffsetInMeters || 0,
),
maneuverIcon: icon,
streetName: inst.street,
isActive: index === 0,
});
},
);
}
// Si pas d'instructions, creer des instructions basiques
if (navInstructions.length === 0) {
navInstructions.push({
instruction: "Dirigez-vous vers votre destination",
distance: formatDistance(
route.summary?.lengthInMeters || 0,
),
maneuverIcon: faCar,
isActive: true,
});
navInstructions.push({
instruction: "Vous etes arrive a destination",
distance: "0 m",
maneuverIcon: faFlagCheckered,
isActive: false,
});
}
setInstructions(navInstructions);
setCurrentInstructionIndex(0);
// Construire le GeoJSON pour la ligne
const geojson = {
type: "Feature" as const,
properties: {},
geometry: {
type: "LineString" as const,
coordinates: coordinates,
},
};
console.log("GeoJSON cree avec", coordinates.length, "points");
// Supprimer l'ancien itineraire s'il existe
try {
if (mapInstance.current?.getLayer(routeLayerId.current)) {
mapInstance.current.removeLayer(routeLayerId.current);
}
if (mapInstance.current?.getSource(routeLayerId.current)) {
mapInstance.current.removeSource(routeLayerId.current);
}
} catch (e) {
console.log("Pas d'ancien itineraire a supprimer");
}
// Ajouter le nouvel itineraire
mapInstance.current?.addSource(routeLayerId.current, {
type: "geojson",
data: geojson,
});
mapInstance.current?.addLayer({
id: routeLayerId.current,
type: "line",
source: routeLayerId.current,
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#4285F4",
"line-width": 6,
"line-opacity": 0.8,
},
});
console.log("Itineraire ajoute a la carte");
// Informations de l'itineraire
const summary = route.summary;
const distanceKm = (summary.lengthInMeters / 1000).toFixed(1);
const durationMin = Math.round(summary.travelTimeInSeconds / 60);
const info: RouteInfo = {
distance: `${distanceKm} km`,
duration: `${durationMin} min`,
distanceRemaining: summary.lengthInMeters,
timeRemaining: summary.travelTimeInSeconds,
};
setRouteInfo(info);
onRouteCalculated?.(info.distance, info.duration);
// Ajuster la vue pour montrer tout l'itineraire
const bounds = new tt.LngLatBounds();
bounds.extend([driverLocation.longitude, driverLocation.latitude]);
bounds.extend([destinationCoords.lng, destinationCoords.lat]);
mapInstance.current?.fitBounds(bounds, {
padding: 80,
});
console.log(
"Itineraire calcule avec",
navInstructions.length,
"instructions",
);
} catch (err) {
console.error("Erreur calcul itineraire:", err);
setError("Erreur lors du calcul de l'itineraire");
onError?.("Erreur lors du calcul de l'itineraire");
} finally {
setIsLoading(false);
}
}, [
driverLocation,
destinationCoords,
onRouteCalculated,
onError,
formatDistance,
]);
// Recentrer sur le livreur
const centerOnDriver = () => {
if (mapInstance.current && driverLocation) {
mapInstance.current.flyTo({
center: [driverLocation.longitude, driverLocation.latitude],
zoom: 17,
});
}
};
if (
error &&
(!TOMTOM_API_KEY || TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE")
) {
return (
<div className="tomtom-map-container">
<div className="tomtom-map-error">
<div className="error-icon">
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="48"
height="48"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
</div>
<h3>Configuration requise</h3>
<p>{error}</p>
<ol>
<li>
Allez sur{" "}
<a
href="https://developer.tomtom.com/"
target="_blank"
rel="noopener noreferrer"
>
developer.tomtom.com
</a>
</li>
<li>Creez un compte gratuit</li>
<li>Obtenez une cle API</li>
<li>
Ajoutez-la dans le fichier <code>.env</code>
</li>
</ol>
</div>
</div>
);
}
const currentInstruction = instructions[currentInstructionIndex];
const nextInstruction = instructions[currentInstructionIndex + 1];
return (
<div
className={`tomtom-map-container ${isNavigating ? "navigating" : ""}`}
>
{isLoading && (
<div className="tomtom-map-loading">
<div className="loading-spinner"></div>
<span>Calcul de l'itineraire...</span>
</div>
)}
{/* Panneau d'instruction principale */}
{isNavigating && currentInstruction && (
<div className="navigation-panel">
<div className="current-instruction">
<div className="instruction-maneuver">
<FontAwesomeIcon
icon={currentInstruction.maneuverIcon}
/>
</div>
<div className="instruction-details">
<div className="instruction-text">
{currentInstruction.instruction}
</div>
{currentInstruction.streetName && (
<div className="instruction-street">
{currentInstruction.streetName}
</div>
)}
</div>
<div className="instruction-distance">
{currentInstruction.distance}
</div>
</div>
{nextInstruction && (
<div className="next-instruction">
<span className="next-label">Puis</span>
<span className="next-maneuver">
<FontAwesomeIcon
icon={nextInstruction.maneuverIcon}
/>
</span>
<span className="next-text">
{nextInstruction.instruction}
</span>
</div>
)}
</div>
)}
<div ref={mapContainer} className="tomtom-map" />
{/* Bouton recentrer */}
{driverLocation && (
<button
className="recenter-btn"
onClick={centerOnDriver}
title="Recentrer"
>
<FontAwesomeIcon icon={faLocationCrosshairs} />
</button>
)}
{/* Infos de route */}
{routeInfo && (
<div className="tomtom-route-info">
<div className="route-info-item">
<span className="route-info-label">Distance</span>
<span className="route-info-value">
{routeInfo.distance}
</span>
</div>
<div className="route-info-divider" />
<div className="route-info-item">
<span className="route-info-label">Duree</span>
<span className="route-info-value">
{routeInfo.duration}
</span>
</div>
{instructions.length > 0 && (
<>
<div className="route-info-divider" />
<button
className="show-instructions-btn"
onClick={() =>
setShowAllInstructions(!showAllInstructions)
}
>
<FontAwesomeIcon
icon={faRoute}
style={{ marginRight: "0.5rem" }}
/>
{showAllInstructions ? "Masquer" : "Etapes"} (
{instructions.length})
</button>
</>
)}
</div>
)}
{/* Liste des instructions */}
{showAllInstructions && instructions.length > 0 && (
<div className="instructions-list">
<div className="instructions-header">
<h4>Etapes de l'itineraire</h4>
<button
className="close-instructions-btn"
onClick={() => setShowAllInstructions(false)}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<div className="instructions-scroll">
{instructions.map((inst, index) => (
<div
key={index}
className={`instruction-item ${index === currentInstructionIndex ? "active" : ""} ${index < currentInstructionIndex ? "passed" : ""}`}
>
<span className="item-maneuver">
<FontAwesomeIcon icon={inst.maneuverIcon} />
</span>
<div className="item-details">
<span className="item-text">
{inst.instruction}
</span>
{inst.streetName && (
<span className="item-street">
{inst.streetName}
</span>
)}
</div>
<span className="item-distance">
{inst.distance}
</span>
</div>
))}
</div>
</div>
)}
{error && <div className="tomtom-map-error-toast">{error}</div>}
</div>
);
}
export default TomTomMap;
@@ -1,306 +0,0 @@
.admin-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.dashboard-header {
margin-bottom: clamp(2rem, 5vw, 3rem);
text-align: center;
}
.dashboard-header h1 {
color: white;
font-size: clamp(2rem, 6vw, 3rem);
margin: 0 0 0.5rem 0;
font-weight: bold;
letter-spacing: -0.5px;
background: linear-gradient(to right, #7c3aed, #10b981);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dashboard-subtitle {
color: #888;
font-size: clamp(1rem, 3vw, 1.2rem);
margin: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.stat-card {
position: relative;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
align-items: center;
gap: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(124, 58, 237, 0.1), transparent);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.stat-card:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.2);
transform: translateY(-4px);
}
.stat-card:hover::before {
left: 100%;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-card:hover .stat-icon {
transform: scale(1.1) rotate(5deg);
}
.stat-icon.purple {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
color: #7c3aed;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
}
.stat-icon.orange {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(249, 115, 22, 0.1));
color: #fb923c;
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
}
.stat-icon.green {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
color: #10b981;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
.stat-icon.blue {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
color: #3b82f6;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
}
.stat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-label {
color: #888;
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.stat-value {
color: white;
font-size: clamp(1.8rem, 5vw, 2.2rem);
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 600;
}
.stat-trend.positive {
color: #10b981;
}
.stat-trend.neutral {
color: #fb923c;
}
.stat-trend.negative {
color: #ef4444;
}
/* Quick Actions Section */
.quick-actions-section {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.quick-actions-section h2 {
color: white;
font-size: clamp(1.5rem, 4vw, 2rem);
margin: 0 0 clamp(1.5rem, 4vw, 2rem) 0;
font-weight: bold;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
}
.quick-action-card {
position: relative;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.quick-action-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(124, 58, 237, 0.1), transparent);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.quick-action-card:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(109, 40, 217, 0.05) 100%);
border-color: rgba(124, 58, 237, 0.4);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.3);
transform: translateY(-4px);
}
.quick-action-card:hover::before {
left: 100%;
}
.quick-action-card:active {
transform: scale(0.98);
}
.quick-action-card svg {
color: #7c3aed;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.quick-action-card:hover svg {
transform: scale(1.1) rotate(5deg);
}
.quick-action-card span {
color: white;
font-size: clamp(1rem, 3vw, 1.1rem);
font-weight: 600;
text-align: center;
}
/* Loading */
.loading-dashboard {
text-align: center;
padding: 4rem 2rem;
}
.loading-dashboard p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Responsive */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.actions-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-icon {
width: 50px;
height: 50px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.actions-grid {
grid-template-columns: 1fr;
}
}
@media (hover: none) {
.stat-card:hover {
transform: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.quick-action-card:hover {
transform: none;
}
.view-all-button:hover {
transform: none;
}
}
-179
View File
@@ -1,179 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Users, Package, CheckCircle, Clock } from "lucide-react";
import AdminLayout from "../../components/AdminLayout";
import "./AdminDashboard.css";
import {
getAllClients,
getCommandCountByStatus,
getCommandCountCompleted,
getCommandCountInRoute,
isAdminAuthenticated,
} from "../../api/api_admin";
interface Stats {
pendingOrders: number;
completedOrders: number;
totalRevenue: number;
activeDeliveries: number;
totalUsers: number;
}
function Dashboard() {
const navigate = useNavigate();
const [stats, setStats] = useState<Stats>({
pendingOrders: 0,
completedOrders: 0,
totalRevenue: 0,
activeDeliveries: 0,
totalUsers: 0,
});
const [loading, setLoading] = useState(true);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminDashboard] Admin non authentifié, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminDashboard] Session admin expirée, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
const fetchStats = async () => {
// ✅ Vérifier l'auth avant de charger les données
if (!isAdminAuthenticated()) {
console.log("❌ [fetchStats] Admin non authentifié");
navigate("/login-admin/admin", { replace: true });
return;
}
try {
const clients = await getAllClients(); // nombre total d'utilisateurs
const pendingOrders = await getCommandCountByStatus(); // commandes en attente
const completedOrders = await getCommandCountCompleted();
const activeDeliveries = await getCommandCountInRoute();
setStats({
pendingOrders, // récupéré via API
completedOrders, // TODO: remplacer par API réelle plus tard
totalRevenue: 45678.9, // idem
activeDeliveries, // idem
totalUsers: clients.length, // réel
});
} catch (error) {
console.error("❌ Erreur chargement stats:", error);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [AdminDashboard] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
}
} finally {
setLoading(false);
}
};
fetchStats();
}, [navigate]);
if (loading) {
return (
<AdminLayout>
<div className="admin-container">
<div className="loading-dashboard">
<p>Chargement du tableau de bord...</p>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="admin-container">
{/* Header */}
<div className="dashboard-header">
<h1>Tableau de Bord Administrateur</h1>
<p className="dashboard-subtitle">
Vue d'ensemble de votre activité
</p>
</div>
{/* Stats Cards */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon orange">
<Clock size={24} />
</div>
<div className="stat-content">
<p className="stat-label">En Attente</p>
<h3 className="stat-value">
{stats.pendingOrders}
</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon green">
<CheckCircle size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Complétées</p>
<h3 className="stat-value">
{stats.completedOrders}
</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon purple">
<Package size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Livraisons Actives</p>
<h3 className="stat-value">
{stats.activeDeliveries}
</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon green">
<Users size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Utilisateurs</p>
<h3 className="stat-value">{stats.totalUsers}</h3>
</div>
</div>
</div>
</div>
</AdminLayout>
);
}
export default Dashboard;
@@ -1,165 +0,0 @@
/* Ajoutez ces styles à la fin du fichier CabineAlerts.css */
/* ============================================
BOUTON DE SUPPRESSION
============================================ */
.alert-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.alert-btn-danger:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
transform: translateY(-2px);
}
/* ============================================
DELETE CONFIRMATION MODAL
============================================ */
.delete-confirm-modal {
background: linear-gradient(
135deg,
rgba(20, 20, 20, 0.98) 0%,
rgba(10, 10, 10, 0.98) 100%
);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 20px;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(239, 68, 68, 0.4);
backdrop-filter: blur(20px);
}
.delete-modal-header {
text-align: center;
margin-bottom: 1.5rem;
}
.delete-icon {
color: #ef4444;
margin-bottom: 1rem;
animation: pulse-warning 2s ease-in-out infinite;
}
@keyframes pulse-warning {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
.delete-modal-header h3 {
color: white;
font-size: 1.5rem;
margin: 0;
font-weight: 700;
}
.delete-modal-body {
margin-bottom: 2rem;
text-align: center;
}
.delete-modal-body p {
color: #ccc;
font-size: 1rem;
margin: 0.5rem 0;
line-height: 1.6;
}
.delete-modal-body strong {
color: #ef4444;
font-weight: 700;
}
.delete-warning {
color: #fb923c !important;
font-size: 0.9rem !important;
font-weight: 600;
margin-top: 1rem !important;
}
.delete-modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.delete-btn {
flex: 1;
max-width: 200px;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.delete-btn-cancel {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
}
.delete-btn-cancel:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.delete-btn-confirm {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.delete-btn-confirm:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
transform: translateY(-2px);
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
/* Responsive pour la modal de suppression */
@media (max-width: 640px) {
.delete-confirm-modal {
padding: 1.5rem;
}
.delete-modal-actions {
flex-direction: column;
}
.delete-btn {
max-width: 100%;
}
}
@@ -1,727 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
Clock,
CheckCircle,
User,
Shield,
Search,
Filter,
Calendar,
X,
Eye,
Trash2,
} from "lucide-react";
import "./AdminAlerts.css";
import Sidebar from "../../components/Sidebar";
import { isAdminAuthenticated, deleteAlertAdmin } from "../../api/api_admin";
import { getAllAlerts, getAlertDetails } from "../../api/api_cabine";
import Toast from "../../components/Toast";
interface Alert {
id: number;
username: string;
status: string;
created_at: string;
updated_at: string;
}
interface AlertStats {
total: number;
active: number;
resolved: number;
}
interface ToastState {
show: boolean;
message: string;
type: "success" | "error" | "warning" | "info";
}
function AdminAlerts() {
const navigate = useNavigate();
const [alerts, setAlerts] = useState<Alert[]>([]);
const [filteredAlerts, setFilteredAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "true" | "false">(
"true",
);
const [stats, setStats] = useState<AlertStats>({
total: 0,
active: 0,
resolved: 0,
});
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [alertToDelete, setAlertToDelete] = useState<number | null>(null);
// État pour le Toast
const [toast, setToast] = useState<ToastState>({
show: false,
message: "",
type: "success",
});
// Fonction pour afficher un toast
const showToast = (
message: string,
type: "success" | "error" | "warning" | "info" = "success",
) => {
setToast({ show: true, message, type });
};
// Fonction pour fermer le toast
const handleCloseToast = () => {
setToast({ ...toast, show: false });
};
// ============================================
// 🔐 VÉRIFICATION AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isAdminAuthenticated()) {
console.log("❌ [AdminAlerts] Non authentifié, redirection");
navigate("/login-admin", { replace: true });
}
};
checkAuth();
const authInterval = setInterval(() => {
if (!isAdminAuthenticated()) {
console.log("❌ [AdminAlerts] Session expirée");
navigate("/login-admin", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// ============================================
// 📊 CHARGEMENT DES ALERTES
// ============================================
const fetchAlerts = async () => {
if (!isAdminAuthenticated()) {
navigate("/login-admin", { replace: true });
return;
}
try {
console.log("🚨 [ADMIN_ALERTS] Chargement des alertes...");
const result = await getAllAlerts();
if (result.success && result.alerts) {
console.log(
"✅ [ADMIN_ALERTS] Alertes récupérées:",
result.alerts,
);
const alertsData = result.alerts.map((alert: any) => ({
id: alert.id,
username: alert.username,
status: alert.status,
created_at: alert.created_at,
updated_at: alert.updated_at,
}));
setAlerts(alertsData);
// Calculer les stats
const activeCount = alertsData.filter(
(a: Alert) => a.status === "true",
).length;
const resolvedCount = alertsData.filter(
(a: Alert) => a.status === "false",
).length;
setStats({
total: alertsData.length,
active: activeCount,
resolved: resolvedCount,
});
} else {
console.warn("⚠️ [ADMIN_ALERTS] Aucune alerte trouvée");
setAlerts([]);
setFilteredAlerts([]);
setStats({
total: 0,
active: 0,
resolved: 0,
});
}
} catch (error) {
console.error("❌ [ADMIN_ALERTS] Erreur chargement:", error);
if (error instanceof Error && error.message.includes("401")) {
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin", { replace: true });
}
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAlerts();
// Rafraîchir toutes les 30 secondes
const interval = setInterval(fetchAlerts, 30000);
return () => clearInterval(interval);
}, [navigate]);
// ============================================
// 🔍 FILTRAGE DES ALERTES
// ============================================
useEffect(() => {
let filtered = [...alerts];
// Filtre par statut
if (statusFilter !== "all") {
filtered = filtered.filter(
(alert) => alert.status === statusFilter,
);
}
// Filtre par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((alert) =>
alert.username.toLowerCase().includes(query),
);
}
setFilteredAlerts(filtered);
}, [alerts, statusFilter, searchQuery]);
// ============================================
// 🎨 HELPERS
// ============================================
const determinePriority = (
alert: Alert,
): "critical" | "high" | "medium" | "low" => {
if (alert.status === "true") {
const createdAt = new Date(alert.created_at);
const now = new Date();
const durationMinutes =
(now.getTime() - createdAt.getTime()) / 1000 / 60;
if (durationMinutes > 30) return "critical";
if (durationMinutes > 15) return "high";
if (durationMinutes > 5) return "medium";
}
return "low";
};
const getStatusLabel = (status: string) => {
switch (status) {
case "true":
return "Active";
case "false":
return "Résolue";
default:
return "Inconnu";
}
};
const formatDuration = (createdAt: string) => {
const created = new Date(createdAt);
const now = new Date();
const duration = Math.floor((now.getTime() - created.getTime()) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const secs = duration % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatTimeAgo = (dateString: string) => {
const seconds = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000,
);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}j`;
};
const handleViewDetails = async (alertId: number) => {
try {
const result = await getAlertDetails(alertId);
if (result.success && result.alert) {
setSelectedAlert(result.alert as Alert);
setShowDetailsModal(true);
} else {
showToast(
"Impossible de récupérer les détails de l'alerte",
"error",
);
}
} catch (error) {
console.error("❌ Erreur récupération détails:", error);
showToast("Erreur lors de la récupération des détails", "error");
}
};
const handleDeleteClick = (alertId: number, e: React.MouseEvent) => {
e.stopPropagation();
setAlertToDelete(alertId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = async () => {
if (!alertToDelete) return;
try {
console.log("🗑️ [DELETE_ALERT] Suppression:", alertToDelete);
const result = await deleteAlertAdmin(alertToDelete);
if (result.success) {
console.log("✅ [DELETE_ALERT] Suppression réussie");
// Rafraîchir la liste
await fetchAlerts();
// Fermer la modal de confirmation
setShowDeleteConfirm(false);
setAlertToDelete(null);
// Afficher le toast de succès
showToast(
`Alerte #${alertToDelete} supprimée avec succès`,
"success",
);
} else {
console.error("❌ [DELETE_ALERT] Erreur:", result.error);
showToast(
result.error || "Erreur lors de la suppression",
"error",
);
}
} catch (error) {
console.error("❌ [DELETE_ALERT] Erreur:", error);
showToast("Erreur lors de la suppression de l'alerte", "error");
}
};
const handleCancelDelete = () => {
setShowDeleteConfirm(false);
setAlertToDelete(null);
};
// ============================================
// 🎨 RENDER
// ============================================
if (loading) {
return (
<div className="alerts-page-container">
<div className="alerts-loading">
<p>Chargement des alertes...</p>
</div>
</div>
);
}
return (
<>
<Sidebar />
<div className="alerts-page-container">
{/* Header */}
<div className="alerts-page-header">
<h1>
<AlertTriangle size={40} />
Gestion des Alertes
</h1>
<p className="alerts-page-subtitle">
Surveillance en temps réel des alertes police
</p>
</div>
{/* Stats Grid */}
<div className="alerts-stats-grid">
<div className="alert-stat-card">
<div className="alert-stat-icon red">
<AlertTriangle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Total Alertes</p>
<h3 className="alert-stat-value">{stats.total}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon orange">
<Shield size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Alertes Actives</p>
<h3 className="alert-stat-value">{stats.active}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon green">
<CheckCircle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Résolues</p>
<h3 className="alert-stat-value">
{stats.resolved}
</h3>
</div>
</div>
</div>
{/* Filters */}
<div className="alerts-filters">
<div className="alerts-search">
<Search size={20} />
<input
type="text"
placeholder="Rechercher par livreur..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="alerts-filter-buttons">
<button
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
onClick={() => setStatusFilter("all")}
>
<Filter size={16} />
Toutes ({alerts.length})
</button>
<button
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
onClick={() => setStatusFilter("true")}
>
<Shield size={16} />
Actives ({stats.active})
</button>
<button
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
onClick={() => setStatusFilter("false")}
>
<CheckCircle size={16} />
Résolues ({stats.resolved})
</button>
</div>
</div>
{/* Alerts List */}
<div className="alerts-section">
<div className="alerts-section-header">
<h2>
<Shield size={24} />
Liste des Alertes
</h2>
<div className="alerts-count-badge">
{filteredAlerts.length} alerte
{filteredAlerts.length > 1 ? "s" : ""}
</div>
</div>
{filteredAlerts.length > 0 ? (
<div className="alerts-list">
{filteredAlerts.map((alert) => {
const priority = determinePriority(alert);
return (
<div
key={alert.id}
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
onClick={() =>
handleViewDetails(alert.id)
}
>
<div className="alert-card-header">
<div className="alert-priority-indicator">
<div
className={`alert-priority-icon ${priority}`}
>
<AlertTriangle size={24} />
</div>
<div className="alert-info">
<p className="alert-id">
Alerte #{alert.id}
</p>
<h3 className="alert-title">
{alert.username}
</h3>
<p className="alert-subtitle">
<Clock size={14} />
Il y a{" "}
{formatTimeAgo(
alert.created_at,
)}
</p>
</div>
</div>
<div
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
>
{alert.status === "true" ? (
<AlertTriangle size={16} />
) : (
<CheckCircle size={16} />
)}
{getStatusLabel(alert.status)}
</div>
</div>
<div className="alert-details">
<div className="alert-detail-item">
<User size={18} />
<div className="alert-detail-content">
<p className="alert-detail-label">
Livreur
</p>
<p className="alert-detail-value">
{alert.username}
</p>
</div>
</div>
<div className="alert-detail-item">
<Clock size={18} />
<div className="alert-detail-content">
<p className="alert-detail-label">
Durée
</p>
<p className="alert-detail-value">
{formatDuration(
alert.created_at,
)}
</p>
</div>
</div>
</div>
<div className="alert-actions">
<button
className="alert-btn alert-btn-outline"
onClick={(e) => {
e.stopPropagation();
handleViewDetails(alert.id);
}}
>
<Eye size={18} />
Détails
</button>
<button
className="alert-btn alert-btn-danger"
onClick={(e) =>
handleDeleteClick(
alert.id,
e,
)
}
>
<Trash2 size={18} />
Supprimer
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="alerts-empty-state">
<Shield size={64} />
<h3>Aucune alerte trouvée</h3>
<p>
{searchQuery
? "Essayez de modifier vos critères de recherche"
: statusFilter === "true"
? "Aucune alerte active actuellement"
: "Aucune alerte n'est actuellement enregistrée"}
</p>
</div>
)}
</div>
{/* Details Modal */}
{showDetailsModal && selectedAlert && (
<div
className="modal-overlay"
onClick={() => setShowDetailsModal(false)}
>
<div
className="location-modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="location-modal-header">
<div className="location-header-title">
<AlertTriangle
size={24}
className="pulse-icon"
/>
<h3>
Détails de l'Alerte #{selectedAlert.id}
</h3>
</div>
<button
className="location-close-btn"
onClick={() => setShowDetailsModal(false)}
>
<X size={20} />
</button>
</div>
<div className="location-modal-body">
<div className="location-info-card">
<div className="location-info-header">
<User size={20} />
<span>Informations Livreur</span>
</div>
<div className="location-info-content">
<div className="location-info-row">
<span className="location-label">
Username:
</span>
<span className="location-value">
{selectedAlert.username}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Statut:
</span>
<span className="location-value">
{getStatusLabel(
selectedAlert.status,
)}
</span>
</div>
</div>
</div>
<div className="location-update-card">
<div className="location-update-header">
<Calendar size={20} />
<span>Informations Temporelles</span>
</div>
<div className="location-update-content">
<div className="location-info-row">
<span className="location-label">
Créée le:
</span>
<span className="location-value">
{formatDate(
selectedAlert.created_at,
)}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Durée:
</span>
<span className="location-value">
{formatDuration(
selectedAlert.created_at,
)}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Dernière mise à jour:
</span>
<span className="location-value">
{formatDate(
selectedAlert.updated_at,
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="modal-overlay" onClick={handleCancelDelete}>
<div
className="delete-confirm-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="delete-modal-header">
<AlertTriangle
size={48}
className="delete-icon"
/>
<h3>Confirmer la suppression</h3>
</div>
<div className="delete-modal-body">
<p>
Êtes-vous sûr de vouloir supprimer l'alerte{" "}
<strong>#{alertToDelete}</strong> ?
</p>
<p className="delete-warning">
Cette action est irréversible.
</p>
</div>
<div className="delete-modal-actions">
<button
className="delete-btn delete-btn-cancel"
onClick={handleCancelDelete}
>
Annuler
</button>
<button
className="delete-btn delete-btn-confirm"
onClick={handleConfirmDelete}
>
<Trash2 size={18} />
Supprimer
</button>
</div>
</div>
</div>
)}
{/* Toast Notifications */}
{toast.show && (
<Toast
message={toast.message}
type={toast.type}
duration={3000}
onClose={handleCloseToast}
/>
)}
</div>
</>
);
}
export default AdminAlerts;
File diff suppressed because it is too large Load Diff
@@ -1,965 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Users,
Search,
Filter,
MapPin,
Clock,
Package,
TrendingUp,
CheckCircle,
AlertCircle,
XCircle,
ChevronDown,
RefreshCw,
Truck,
Eye,
User,
Phone,
ExternalLink,
Copy,
} from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMapLocationDot, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { faApple, faGoogle, faWaze } from "@fortawesome/free-brands-svg-icons";
import "./AdminDeliveryMen.css";
import AdminLayout from "../../components/AdminLayout";
import {
getAllDeliveryPersonsWithDetails,
getDeliveryPersonMapLinks,
isAdminAuthenticated, // ⭐ AJOUT - Vérification authentification
} from "../../api/api_admin";
// Types
interface DeliveryPerson {
id: number;
username: string;
nom: string;
prenom: string;
telephone: string;
status: "available" | "busy" | "offline";
location: {
latitude: number;
longitude: number;
last_update: string;
is_recent: boolean;
};
stats: {
total_deliveries: number;
completed_today: number;
queue_size: number;
current_command: number | null;
};
}
interface GlobalStats {
total: number;
available: number;
busy: number;
offline: number;
active_deliveries: number;
}
type FilterStatus = "all" | "available" | "busy" | "offline";
function AdminDeliverymen() {
const navigate = useNavigate();
const [deliveryPersons, setDeliveryPersons] = useState<DeliveryPerson[]>(
[],
);
const [filteredDeliveryPersons, setFilteredDeliveryPersons] = useState<
DeliveryPerson[]
>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
const [showFilters, setShowFilters] = useState(false);
const [selectedDeliveryPerson, setSelectedDeliveryPerson] =
useState<DeliveryPerson | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [mapLinks, setMapLinks] = useState<any>(null);
const [loadingMapLinks, setLoadingMapLinks] = useState(false);
const [copiedCoords, setCopiedCoords] = useState(false);
// Stats globales
const [stats, setStats] = useState<GlobalStats>({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0,
});
// ============================================
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminDeliveryMen] Admin non authentifié, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ============================================
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
// ============================================
useEffect(() => {
const authInterval = setInterval(() => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminDeliveryMen] Session admin expirée, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// Récupération des livreurs via API
useEffect(() => {
fetchDeliveryPersons();
}, []);
const fetchDeliveryPersons = async () => {
// ✅ Vérifier l'auth avant de charger les données
if (!isAdminAuthenticated()) {
console.log("❌ [fetchDeliveryPersons] Admin non authentifié");
navigate("/login-admin/admin", { replace: true });
return;
}
setLoading(true);
try {
console.log("[DELIVERY_PERSONS] Récupération des livreurs...");
const result = await getAllDeliveryPersonsWithDetails();
if (!result.success) {
console.error("[DELIVERY_PERSONS] Erreur:", result);
setDeliveryPersons([]);
setFilteredDeliveryPersons([]);
setStats({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0,
});
setLoading(false);
return;
}
console.log("[DELIVERY_PERSONS] Données reçues:", result);
setDeliveryPersons(result.deliveryPersons);
setFilteredDeliveryPersons(result.deliveryPersons);
setStats(result.stats);
console.log(
"[DELIVERY_PERSONS] Données chargées:",
result.count,
"livreurs",
);
} catch (error) {
console.error("[DELIVERY_PERSONS] Erreur chargement:", error);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [AdminDeliveryMen] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
return;
}
setDeliveryPersons([]);
setFilteredDeliveryPersons([]);
setStats({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0,
});
} finally {
setLoading(false);
}
};
// Filtrage
useEffect(() => {
let filtered = deliveryPersons;
if (filterStatus !== "all") {
filtered = filtered.filter((d) => d.status === filterStatus);
}
if (searchTerm) {
filtered = filtered.filter(
(d) =>
d.username
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
d.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.telephone.includes(searchTerm),
);
}
setFilteredDeliveryPersons(filtered);
}, [searchTerm, filterStatus, deliveryPersons]);
// Charger les liens GPS
const loadMapLinks = async (username: string) => {
setLoadingMapLinks(true);
setMapLinks(null);
try {
console.log("[MAP_LINKS] Chargement pour:", username);
const result = await getDeliveryPersonMapLinks(username);
if (result.success) {
console.log("[MAP_LINKS] Liens chargés:", result);
setMapLinks(result);
} else {
console.error("[MAP_LINKS] Erreur:", result);
}
} catch (error) {
console.error("[MAP_LINKS] Erreur:", error);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [AdminDeliveryMen] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
}
} finally {
setLoadingMapLinks(false);
}
};
// Ouvrir modal avec liens GPS
const handleViewDetails = async (person: DeliveryPerson) => {
setSelectedDeliveryPerson(person);
setShowDetailsModal(true);
setMapLinks(null);
setCopiedCoords(false);
await loadMapLinks(person.username);
};
const handleRefresh = async () => {
setRefreshing(true);
await fetchDeliveryPersons();
setRefreshing(false);
};
const handleCopyCoordinates = () => {
if (mapLinks?.location) {
const coords = `${mapLinks.location.latitude}, ${mapLinks.location.longitude}`;
navigator.clipboard.writeText(coords);
setCopiedCoords(true);
setTimeout(() => setCopiedCoords(false), 2000);
}
};
const getStatusLabel = (status: string) => {
const labels = {
available: "Disponible",
busy: "Occupé",
offline: "Hors ligne",
};
return labels[status as keyof typeof labels] || status;
};
const getStatusCount = (status: FilterStatus) => {
if (status === "all") return stats.total;
return stats[status as keyof typeof stats] || 0;
};
if (loading) {
return (
<AdminLayout>
<div className="admin-container">
<div className="loading-users">
<p>Chargement des livreurs...</p>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="admin-container">
{/* Header */}
<div className="orders-header">
<div className="header-content">
<h1>Gestion des Livreurs</h1>
</div>
<button
className="export-button"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
size={18}
className={refreshing ? "spin-animation" : ""}
/>
<span>Actualiser</span>
</button>
</div>
{/* Stats rapides */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon purple">
<Users size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Total Livreurs</p>
<h3 className="stat-value">{stats.total}</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon green">
<CheckCircle size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Disponibles</p>
<h3 className="stat-value">{stats.available}</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon orange">
<Truck size={24} />
</div>
<div className="stat-content">
<p className="stat-label">En Livraison</p>
<h3 className="stat-value">{stats.busy}</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon blue">
<Package size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Livraisons Actives</p>
<h3 className="stat-value">
{stats.active_deliveries}
</h3>
</div>
</div>
</div>
{/* Contrôles */}
<div className="orders-controls">
<div className="search-bar">
<Search size={20} />
<input
type="text"
placeholder="Rechercher par nom, username, téléphone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
className={`filter-toggle ${showFilters ? "active" : ""}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
<span>Filtres</span>
<ChevronDown
size={16}
className={showFilters ? "rotated" : ""}
/>
</button>
</div>
{/* Filtres */}
{showFilters && (
<div className="filters-panel">
<div className="filter-group">
<label>Statut</label>
<div className="filter-buttons">
<button
className={
filterStatus === "all" ? "active" : ""
}
onClick={() => setFilterStatus("all")}
>
Tous ({getStatusCount("all")})
</button>
<button
className={
filterStatus === "available"
? "active"
: ""
}
onClick={() => setFilterStatus("available")}
>
Disponibles ({getStatusCount("available")})
</button>
<button
className={
filterStatus === "busy" ? "active" : ""
}
onClick={() => setFilterStatus("busy")}
>
Occupés ({getStatusCount("busy")})
</button>
<button
className={
filterStatus === "offline"
? "active"
: ""
}
onClick={() => setFilterStatus("offline")}
>
Hors ligne ({getStatusCount("offline")})
</button>
</div>
</div>
</div>
)}
{/* Liste des livreurs */}
<div className="orders-list">
{filteredDeliveryPersons.length === 0 ? (
<div className="empty-state">
<Users size={64} />
<h3>Aucun livreur trouvé</h3>
<p>
Aucun livreur ne correspond à vos critères de
recherche
</p>
</div>
) : (
filteredDeliveryPersons.map((person) => (
<div key={person.id} className="order-card">
<div className="order-header">
<div className="order-number">
<User size={20} />
<span>
{person.prenom} {person.nom}
</span>
</div>
<div
className={`order-status status-${person.status === "available" ? "approved" : person.status === "busy" ? "en_route" : "cancelled"}`}
>
{person.status === "available" && (
<CheckCircle size={16} />
)}
{person.status === "busy" && (
<Truck size={16} />
)}
{person.status === "offline" && (
<XCircle size={16} />
)}
<span>
{getStatusLabel(person.status)}
</span>
</div>
</div>
<div className="order-content">
<div className="order-info-grid">
<div className="info-item">
<User size={16} />
<div className="info-details">
<span className="info-label">
Username
</span>
<span className="info-value">
{person.username}
</span>
</div>
</div>
<div className="info-item">
<Phone size={16} />
<div className="info-details">
<span className="info-label">
Téléphone
</span>
<span className="info-value">
{person.telephone}
</span>
</div>
</div>
<div className="info-item">
<Package size={16} />
<div className="info-details">
<span className="info-label">
Livraisons totales
</span>
<span className="info-value">
{
person.stats
.total_deliveries
}
</span>
</div>
</div>
<div className="info-item">
<TrendingUp size={16} />
<div className="info-details">
<span className="info-label">
Complétées aujourd'hui
</span>
<span className="info-value">
{
person.stats
.completed_today
}
</span>
</div>
</div>
<div className="info-item">
<Clock size={16} />
<div className="info-details">
<span className="info-label">
File d'attente
</span>
<span className="info-value">
{person.stats.queue_size}{" "}
commande(s)
</span>
</div>
</div>
<div className="info-item">
<MapPin size={16} />
<div className="info-details">
<span className="info-label">
Position GPS
</span>
<span className="info-value">
{person.location.latitude.toFixed(
4,
)}
°,{" "}
{person.location.longitude.toFixed(
4,
)}
°
</span>
</div>
</div>
</div>
{person.stats.current_command && (
<div className="order-items">
<span className="items-label">
Commande en cours:
</span>
<span className="item-tag">
CMD-
{person.stats.current_command
.toString()
.padStart(6, "0")}
</span>
</div>
)}
</div>
<div className="order-footer">
<button
className="view-details-button"
onClick={() =>
handleViewDetails(person)
}
>
<Eye size={18} />
<span>Détails</span>
</button>
</div>
</div>
))
)}
</div>
{/* Modal détails livreur */}
{showDetailsModal && selectedDeliveryPerson && (
<>
<div
className="modal-overlay"
onClick={() => setShowDetailsModal(false)}
/>
<div className="order-details-modal">
<div className="modal-header">
<h2>Détails du livreur</h2>
<button
className="close-modal"
onClick={() => setShowDetailsModal(false)}
>
<XCircle size={24} />
</button>
</div>
<div className="modal-content">
<div className="detail-section">
<h3>Informations personnelles</h3>
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">
Nom complet
</span>
<span className="detail-value">
{selectedDeliveryPerson.prenom}{" "}
{selectedDeliveryPerson.nom}
</span>
</div>
<div className="detail-item">
<span className="detail-label">
Username
</span>
<span className="detail-value">
{
selectedDeliveryPerson.username
}
</span>
</div>
<div className="detail-item">
<span className="detail-label">
Téléphone
</span>
<span className="detail-value">
{
selectedDeliveryPerson.telephone
}
</span>
</div>
<div className="detail-item">
<span className="detail-label">
Statut
</span>
<div
className={`order-status status-${selectedDeliveryPerson.status === "available" ? "approved" : selectedDeliveryPerson.status === "busy" ? "en_route" : "cancelled"}`}
>
{selectedDeliveryPerson.status ===
"available" && (
<CheckCircle size={16} />
)}
{selectedDeliveryPerson.status ===
"busy" && (
<Truck size={16} />
)}
{selectedDeliveryPerson.status ===
"offline" && (
<XCircle size={16} />
)}
<span>
{getStatusLabel(
selectedDeliveryPerson.status,
)}
</span>
</div>
</div>
</div>
</div>
<div className="detail-section">
<h3>Statistiques</h3>
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">
Total livraisons
</span>
<span className="detail-value">
{
selectedDeliveryPerson.stats
.total_deliveries
}
</span>
</div>
<div className="detail-item">
<span className="detail-label">
Complétées aujourd'hui
</span>
<span className="detail-value">
{
selectedDeliveryPerson.stats
.completed_today
}
</span>
</div>
<div className="detail-item">
<span className="detail-label">
File d'attente
</span>
<span className="detail-value">
{
selectedDeliveryPerson.stats
.queue_size
}{" "}
commande(s)
</span>
</div>
{selectedDeliveryPerson.stats
.current_command && (
<div className="detail-item">
<span className="detail-label">
Commande en cours
</span>
<span className="detail-value">
CMD-
{selectedDeliveryPerson.stats.current_command
.toString()
.padStart(6, "0")}
</span>
</div>
)}
</div>
</div>
<div className="detail-section">
<h3>Position GPS</h3>
<div
className="gps-info"
style={{ marginTop: "1rem" }}
>
<div className="gps-coordinates">
<div className="coordinate">
<span className="coord-label">
Latitude:
</span>
<span className="coord-value">
{selectedDeliveryPerson.location.latitude.toFixed(
6,
)}
</span>
</div>
<div className="coordinate">
<span className="coord-label">
Longitude:
</span>
<span className="coord-value">
{selectedDeliveryPerson.location.longitude.toFixed(
6,
)}
</span>
</div>
</div>
</div>
</div>
{/* SECTION NAVIGATION GPS */}
<div className="detail-section">
<h3>
<FontAwesomeIcon
icon={faMapLocationDot}
/>{" "}
Navigation GPS
</h3>
{loadingMapLinks ? (
<div className="map-links-loading">
<FontAwesomeIcon
icon={faSpinner}
size="2x"
className="spin-animation"
/>
<span>
Chargement des liens GPS...
</span>
</div>
) : mapLinks?.success ? (
<>
{/* Liens de navigation */}
<div className="map-links-grid">
{/* Google Maps - Vue */}
<a
href={
mapLinks.map_links
.google_maps
}
target="_blank"
rel="noopener noreferrer"
className="map-link-button google"
>
<FontAwesomeIcon
icon={faGoogle}
className="map-icon"
/>
<div className="link-content">
<span className="link-title">
Google Maps
</span>
<span className="link-subtitle">
Voir la position
</span>
</div>
<ExternalLink
size={16}
className="external-icon"
/>
</a>
{/* Waze */}
<a
href={
mapLinks.map_links.waze
}
target="_blank"
rel="noopener noreferrer"
className="map-link-button waze"
>
<FontAwesomeIcon
icon={faWaze}
className="map-icon"
/>
<div className="link-content">
<span className="link-title">
Waze
</span>
<span className="link-subtitle">
Navigation GPS
</span>
</div>
<ExternalLink
size={16}
className="external-icon"
/>
</a>
{/* Apple Maps */}
<a
href={
mapLinks.map_links
.apple_maps
}
target="_blank"
rel="noopener noreferrer"
className="map-link-button apple"
>
<FontAwesomeIcon
icon={faApple}
className="map-icon"
/>
<div className="link-content">
<span className="link-title">
Apple Maps
</span>
<span className="link-subtitle">
Ouvrir dans Plans
</span>
</div>
<ExternalLink
size={16}
className="external-icon"
/>
</a>
{/* OpenStreetMap */}
<a
href={
mapLinks.map_links
.openstreetmap
}
target="_blank"
rel="noopener noreferrer"
className="map-link-button osm"
>
<MapPin
size={24}
className="map-icon"
/>
<div className="link-content">
<span className="link-title">
OpenStreetMap
</span>
<span className="link-subtitle">
Carte open source
</span>
</div>
<ExternalLink
size={16}
className="external-icon"
/>
</a>
</div>
{/* Copie des coordonnées */}
<div className="coordinates-copy">
<button
onClick={
handleCopyCoordinates
}
className={`copy-coords-button ${copiedCoords ? "copied" : ""}`}
>
<Copy size={16} />
<span>
{copiedCoords
? "Coordonnées copiées !"
: "Copier coordonnées"}
</span>
</button>
<span className="coords-display">
{mapLinks.location.latitude.toFixed(
6,
)}
,{" "}
{mapLinks.location.longitude.toFixed(
6,
)}
</span>
</div>
</>
) : (
<div className="map-links-error">
<AlertCircle size={48} />
<h4>Position GPS non disponible</h4>
<p>
Le livreur n'a pas encore
partagé sa position GPS ou
celle-ci est trop ancienne.
</p>
</div>
)}
</div>
</div>
<div className="modal-actions">
<button
className="action-button secondary"
onClick={() => setShowDetailsModal(false)}
>
<XCircle size={18} />
<span>Fermer</span>
</button>
<button
className="action-button primary"
onClick={handleRefresh}
>
<RefreshCw size={18} />
<span>Actualiser</span>
</button>
</div>
</div>
</>
)}
</div>
</AdminLayout>
);
}
export default AdminDeliverymen;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,708 +0,0 @@
/* ============================================
ProductsPage.css - STYLE PAGE PRODUITS ADMIN
============================================ */
.admin-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1400px;
margin: 0 auto;
}
/* ============================================
HEADER
============================================ */
.products-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
flex-wrap: wrap;
gap: 1rem;
}
.header-content {
text-align: center;
flex: 1;
}
.header-content h1 {
color: white;
font-size: clamp(2rem, 5vw, 2.5rem);
margin: 0 auto 0.3rem auto;
font-weight: bold;
letter-spacing: -0.5px;
background: linear-gradient(to right, #7c3aed, #10b981);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-subtitle {
color: #888;
font-size: clamp(0.9rem, 2.5vw, 1rem);
margin: 0;
}
.add-product-button {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.5rem;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
color: white;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.add-product-button:hover {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
}
/* ============================================
STATS RAPIDES
============================================ */
.products-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
}
.stat-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.2rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-item:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.3);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.2);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon.purple {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
color: #7c3aed;
}
.stat-icon.green {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
color: #10b981;
}
.stat-icon.blue {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
color: #3b82f6;
}
.stat-icon.orange {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(249, 115, 22, 0.1));
color: #fb923c;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.stat-value {
color: white;
font-size: 1.8rem;
font-weight: bold;
line-height: 1;
}
.stat-label {
color: #888;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* ============================================
CONTRÔLES (RECHERCHE + FILTRES)
============================================ */
.products-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-bar {
flex: 1;
min-width: 280px;
position: relative;
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0 1.2rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-bar:focus-within {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.4);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.search-bar svg {
color: #888;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.95rem;
padding: 1rem 0;
}
.search-bar input::placeholder {
color: #666;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.5rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #888;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.filter-toggle:hover,
.filter-toggle.active {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
border-color: rgba(124, 58, 237, 0.3);
color: #7c3aed;
}
.filter-toggle svg.rotated {
transform: rotate(180deg);
}
/* ============================================
PANEL DE FILTRES
============================================ */
.filters-panel {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filter-group label {
display: block;
color: white;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
.filter-buttons button {
padding: 0.6rem 1.2rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #888;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.filter-buttons button:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.2);
color: white;
}
.filter-buttons button.active {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
border-color: rgba(124, 58, 237, 0.4);
color: #7c3aed;
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
/* ============================================
LISTE DES PRODUITS
============================================ */
.products-list {
display: grid;
gap: 1.5rem;
}
.product-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.product-card:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 8px 32px rgba(124, 58, 237, 0.2);
transform: translateY(-2px);
}
/* ============================================
HEADER DU PRODUIT
============================================ */
.product-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.product-identity {
display: flex;
align-items: center;
gap: 1rem;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-image-placeholder {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
display: flex;
align-items: center;
justify-content: center;
color: #7c3aed;
flex-shrink: 0;
}
.product-name-info {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.product-name {
color: white;
font-size: 1.2rem;
font-weight: 700;
margin: 0;
}
.product-category {
color: #888;
font-size: 0.9rem;
font-weight: 500;
margin: 0;
}
.product-stock {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-stock.in-stock {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.product-stock.out-of-stock {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* ============================================
CONTENT DU PRODUIT
============================================ */
.product-content {
margin-bottom: 1.5rem;
}
.product-description {
color: #aaa;
font-size: 0.95rem;
line-height: 1.6;
margin: 0 0 1.2rem 0;
}
.product-prices {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.prices-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #888;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.8rem;
}
.prices-label svg {
color: #7c3aed;
}
.prices-list {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.price-tag {
padding: 0.4rem 0.8rem;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
border: 1px solid rgba(124, 58, 237, 0.2);
border-radius: 6px;
color: #7c3aed;
font-size: 0.85rem;
font-weight: 600;
}
.product-media-count {
display: flex;
align-items: center;
gap: 0.6rem;
color: #888;
font-size: 0.85rem;
font-weight: 500;
}
.product-media-count svg {
color: #7c3aed;
}
/* ============================================
FOOTER DU PRODUIT (ACTIONS)
============================================ */
.product-footer {
display: flex;
gap: 0.8rem;
padding-top: 1.2rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 100px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.3rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-btn.view {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
border: 1px solid rgba(124, 58, 237, 0.3);
color: #7c3aed;
}
.action-btn.view:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
border-color: rgba(124, 58, 237, 0.5);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
}
.action-btn.edit {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.08));
border: 1px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
}
.action-btn.edit:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
}
.action-btn.delete {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.08));
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.action-btn.delete:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
}
/* ============================================
EMPTY STATE
============================================ */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
.empty-state svg {
color: #444;
margin-bottom: 1.5rem;
}
.empty-state h3 {
color: white;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-state p {
color: #888;
font-size: 1rem;
margin: 0;
}
/* ============================================
LOADING
============================================ */
.loading-products {
text-align: center;
padding: 4rem 2rem;
}
.loading-products p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.products-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.products-header {
flex-direction: column;
align-items: flex-start;
}
.add-product-button {
width: 100%;
justify-content: center;
}
.products-stats {
grid-template-columns: repeat(2, 1fr);
}
.products-controls {
flex-direction: column;
}
.search-bar {
width: 100%;
}
.filter-toggle {
width: 100%;
justify-content: center;
}
.product-footer {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}
@media (max-width: 480px) {
.products-stats {
grid-template-columns: 1fr;
}
.stat-item {
padding: 1rem;
}
.filter-buttons {
flex-direction: column;
}
.filter-buttons button {
width: 100%;
}
.product-card {
padding: 1.2rem;
}
.product-identity {
flex-direction: column;
align-items: flex-start;
}
}
@media (hover: none) {
.product-card:hover {
transform: none;
}
.add-product-button:hover,
.action-btn:hover {
transform: none;
}
}
@media (max-width: 768px) {
.orders-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.header-content {
width: 100%;
order: 1; /* Le titre en premier */
}
.export-button,
.add-product-button,
.add-user-button,
.header-actions {
width: 100%;
order: 2; /* Les boutons en second */
}
.export-button,
.add-product-button,
.add-user-button {
justify-content: center;
}
.header-actions {
justify-content: center;
}
}
@media (max-width: 480px) {
.header-content h1 {
font-size: 1.5rem;
}
.header-subtitle {
font-size: 0.85rem;
}
}
@@ -1,751 +0,0 @@
// ============================================
// pages/admin/AdminProduct.tsx - AVEC SIDEBAR ET PROTECTION
// ============================================
// ✅ Page principale de gestion des produits (Admin)
// ✅ CRUD complet avec multipart/form-data
// ✅ Sidebar intégrée
// ✅ Layout responsive
// ✅ Protection authentification admin
// ✅ Modales de confirmation modernes
// ✅ Système de Toast pour les notifications
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Package,
Plus,
Search,
Filter,
ChevronDown,
Eye,
Edit,
Trash2,
Image,
Video,
DollarSign,
Box,
} from "lucide-react";
import {
getAllProductsAdmin,
deleteProductAdmin,
isAdminAuthenticated,
} from "../../api/api_admin";
import type { Product } from "../../api/api_admin_types";
import ProductCreateModal from "../../components/ProductCreateModal";
import ProductDetailsModal from "../../components/ProductDetailsModal";
import ProductEditModal from "../../components/ProductEditModal";
import Sidebar from "../../components/Sidebar";
import ConfirmModal from "../../components/ConfirmModal";
import Toast from "../../components/Toast";
import "./AdminProduct.css";
const AdminProduct: React.FC = () => {
const navigate = useNavigate();
// ============================================
// 📝 STATE
// ============================================
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(
null,
);
// Modals
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(
null,
);
// Confirm Modal
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [productToDelete, setProductToDelete] = useState<Product | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
// Toast
const [toastMessage, setToastMessage] = useState<string>("");
const [toastType, setToastType] = useState<
"success" | "error" | "warning" | "info"
>("success");
const [showToast, setShowToast] = useState(false);
// ============================================
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminProduct] Admin non authentifié, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ============================================
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
// ============================================
useEffect(() => {
const authInterval = setInterval(() => {
if (!isAdminAuthenticated()) {
console.log(
"❌ [AdminProduct] Session admin expirée, redirection vers /login-admin/admin",
);
navigate("/login-admin/admin", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// ============================================
// 🍞 TOAST HELPER
// ============================================
const showToastMessage = (
message: string,
type: "success" | "error" | "warning" | "info" = "success",
) => {
setToastMessage(message);
setToastType(type);
setShowToast(true);
};
// ============================================
// 🔄 CHARGEMENT DES PRODUITS
// ============================================
const loadProducts = async () => {
if (!isAdminAuthenticated()) {
console.log("❌ [loadProducts] Admin non authentifié");
navigate("/login-admin/admin", { replace: true });
return;
}
try {
setLoading(true);
console.log("📦 [PRODUCTS_PAGE] Chargement des produits...");
const response = await getAllProductsAdmin();
if (response.success && response.data) {
setProducts(response.data);
console.log(
`✅ [PRODUCTS_PAGE] ${response.data.length} produits chargés`,
);
} else {
console.error("❌ [PRODUCTS_PAGE] Erreur:", response.error);
setProducts([]);
showToastMessage(
"Erreur lors du chargement des produits",
"error",
);
}
} catch (error) {
console.error("❌ [PRODUCTS_PAGE] Erreur chargement:", error);
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [AdminProduct] Token invalide - Redirection");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
}
setProducts([]);
showToastMessage("Erreur lors du chargement des produits", "error");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProducts();
}, []);
// ============================================
// 🔍 FILTRAGE
// ============================================
const filteredProducts = products.filter((product) => {
const matchesSearch =
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.description
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesCategory =
!selectedCategory || product.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// ============================================
// 📊 STATISTIQUES
// ============================================
const stats = {
total: products.length,
weedHash: products.filter((p) => p.category === "weed&hash").length,
zipette: products.filter((p) => p.category === "zipette&co").length,
gros: products.filter((p) => p.category === "gros&semi").length,
};
// ============================================
// ⚡ ACTIONS
// ============================================
const handleView = (product: Product) => {
setSelectedProduct(product);
setShowDetailsModal(true);
};
const handleEdit = (product: Product) => {
setSelectedProduct(product);
setShowEditModal(true);
};
const handleDeleteClick = (product: Product) => {
setProductToDelete(product);
setShowConfirmDelete(true);
};
const handleConfirmDelete = async () => {
if (!productToDelete) return;
setIsDeleting(true);
try {
console.log(
"🗑️ [PRODUCTS_PAGE] Suppression produit:",
productToDelete.id,
);
const response = await deleteProductAdmin(productToDelete.id!);
if (response.success) {
console.log("✅ [PRODUCTS_PAGE] Produit supprimé");
showToastMessage(
`Produit "${productToDelete.name}" supprimé avec succès`,
"success",
);
setShowConfirmDelete(false);
setProductToDelete(null);
await loadProducts();
} else {
showToastMessage(
"Erreur lors de la suppression: " + response.error,
"error",
);
}
} catch (error) {
console.error("❌ [PRODUCTS_PAGE] Erreur suppression:", error);
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [AdminProduct] Token invalide - Redirection");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
return;
}
showToastMessage(
"Erreur lors de la suppression du produit",
"error",
);
} finally {
setIsDeleting(false);
}
};
const handleCreateSuccess = () => {
setShowCreateModal(false);
showToastMessage("Produit créé avec succès", "success");
loadProducts();
};
const handleEditSuccess = () => {
setShowEditModal(false);
showToastMessage("Produit modifié avec succès", "success");
loadProducts();
};
// ============================================
// 🎨 HELPER - CATÉGORIE LABEL
// ============================================
const getCategoryLabel = (category: string): string => {
switch (category) {
case "weed&hash":
return "Weed & Hash";
case "zipette&co":
return "Zipette & Co";
case "gros&semi":
return "Gros & Semi";
default:
return category;
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<div className="admin-layout">
{/* ============================================
SIDEBAR
============================================ */}
<Sidebar />
{/* ============================================
MAIN CONTENT
============================================ */}
<div className="admin-main-content">
<div className="admin-container">
{loading ? (
<div className="loading-products">
<p>Chargement des produits...</p>
</div>
) : (
<>
{/* ============================================
HEADER
============================================ */}
<div className="products-header">
<div className="header-content">
<h1>Gestion des Produits</h1>
</div>
<button
className="add-product-button"
onClick={() => setShowCreateModal(true)}
>
<Plus size={20} />
Nouveau Produit
</button>
</div>
{/* ============================================
STATS RAPIDES
============================================ */}
<div className="products-stats">
<div className="stat-item">
<div className="stat-icon purple">
<Package size={24} />
</div>
<div className="stat-info">
<div className="stat-value">
{stats.total}
</div>
<div className="stat-label">
Total Produits
</div>
</div>
</div>
<div className="stat-item">
<div className="stat-icon green">
<Box size={24} />
</div>
<div className="stat-info">
<div className="stat-value">
{stats.weedHash}
</div>
<div className="stat-label">
Weed & Hash
</div>
</div>
</div>
<div className="stat-item">
<div className="stat-icon blue">
<Box size={24} />
</div>
<div className="stat-info">
<div className="stat-value">
{stats.zipette}
</div>
<div className="stat-label">
Zipette & Co
</div>
</div>
</div>
<div className="stat-item">
<div className="stat-icon orange">
<Box size={24} />
</div>
<div className="stat-info">
<div className="stat-value">
{stats.gros}
</div>
<div className="stat-label">
Gros & Semi
</div>
</div>
</div>
</div>
{/* ============================================
CONTRÔLES (RECHERCHE + FILTRES)
============================================ */}
<div className="products-controls">
<div className="search-bar">
<Search size={20} />
<input
type="text"
placeholder="Rechercher un produit..."
value={searchTerm}
onChange={(e) =>
setSearchTerm(e.target.value)
}
/>
</div>
<button
className={`filter-toggle ${showFilters ? "active" : ""}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
Filtres
<ChevronDown
size={18}
className={showFilters ? "rotated" : ""}
/>
</button>
</div>
{/* ============================================
PANEL DE FILTRES
============================================ */}
{showFilters && (
<div className="filters-panel">
<div className="filter-group">
<label>Catégorie</label>
<div className="filter-buttons">
<button
className={
selectedCategory === null
? "active"
: ""
}
onClick={() =>
setSelectedCategory(null)
}
>
Toutes
</button>
<button
className={
selectedCategory ===
"weed&hash"
? "active"
: ""
}
onClick={() =>
setSelectedCategory(
"weed&hash",
)
}
>
Weed & Hash
</button>
<button
className={
selectedCategory ===
"zipette&co"
? "active"
: ""
}
onClick={() =>
setSelectedCategory(
"zipette&co",
)
}
>
Zipette & Co
</button>
<button
className={
selectedCategory ===
"gros&semi"
? "active"
: ""
}
onClick={() =>
setSelectedCategory(
"gros&semi",
)
}
>
Gros & Semi
</button>
</div>
</div>
</div>
)}
{/* ============================================
LISTE DES PRODUITS
============================================ */}
{filteredProducts.length === 0 ? (
<div className="empty-state">
<Package size={64} />
<h3>Aucun produit trouvé</h3>
<p>
{searchTerm || selectedCategory
? "Essayez de modifier vos filtres de recherche"
: "Commencez par créer votre premier produit"}
</p>
</div>
) : (
<div className="products-list">
{filteredProducts.map((product) => (
<div
key={product.id}
className="product-card"
>
{/* Header */}
<div className="product-header">
<div className="product-identity">
{product.media &&
product.media.length > 0 ? (
<div className="product-image">
<img
src={`${product.media[0].url}`}
alt={
product.name
}
/>
</div>
) : (
<div className="product-image-placeholder">
<Package
size={32}
/>
</div>
)}
<div className="product-name-info">
<h3 className="product-name">
{product.name}
</h3>
<p className="product-category">
{getCategoryLabel(
product.category,
)}
</p>
</div>
</div>
<div
className={`product-stock ${product.stock > 0 ? "in-stock" : "out-of-stock"}`}
>
<Box size={18} />
{product.stock}g en stock
</div>
</div>
{/* Content */}
<div className="product-content">
<p className="product-description">
{product.description
.length > 150
? product.description.substring(
0,
150,
) + "..."
: product.description}
</p>
{/* Prix */}
<div className="product-prices">
<div className="prices-label">
<DollarSign size={16} />
Tarifs
</div>
<div className="prices-list">
{product.prices?.map(
(price, idx) => (
<div
key={idx}
className="price-tag"
>
{
price.quantity
}
g {" "}
{
price.price
}
</div>
),
)}
</div>
</div>
{/* Médias */}
{product.media &&
product.media.length >
0 && (
<div className="product-media-count">
<Image size={16} />
{
product.media.filter(
(m) =>
m.type ===
"image",
).length
}{" "}
image
{product.media.filter(
(m) =>
m.type ===
"image",
).length > 1
? "s"
: ""}
{product.media.filter(
(m) =>
m.type ===
"video",
).length > 0 && (
<>
{" • "}
<Video
size={
16
}
/>
{
product.media.filter(
(
m,
) =>
m.type ===
"video",
).length
}{" "}
vidéo
{product.media.filter(
(m) =>
m.type ===
"video",
).length > 1
? "s"
: ""}
</>
)}
</div>
)}
</div>
{/* Footer - Actions */}
<div className="product-footer">
<button
className="action-btn view"
onClick={() =>
handleView(product)
}
>
<Eye size={18} />
Voir
</button>
<button
className="action-btn edit"
onClick={() =>
handleEdit(product)
}
>
<Edit size={18} />
Modifier
</button>
<button
className="action-btn delete"
onClick={() =>
handleDeleteClick(
product,
)
}
>
<Trash2 size={18} />
Supprimer
</button>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
{/* ============================================
MODALS
============================================ */}
{showCreateModal && (
<ProductCreateModal
onClose={() => setShowCreateModal(false)}
onSuccess={handleCreateSuccess}
/>
)}
{showDetailsModal && selectedProduct && (
<ProductDetailsModal
product={selectedProduct}
onClose={() => {
setShowDetailsModal(false);
setSelectedProduct(null);
}}
/>
)}
{showEditModal && selectedProduct && (
<ProductEditModal
product={selectedProduct}
onClose={() => {
setShowEditModal(false);
setSelectedProduct(null);
}}
onSuccess={handleEditSuccess}
/>
)}
{/* ============================================
CONFIRM DELETE MODAL
============================================ */}
<ConfirmModal
isOpen={showConfirmDelete}
onClose={() => {
setShowConfirmDelete(false);
setProductToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Supprimer le produit"
message={`Êtes-vous sûr de vouloir supprimer "${productToDelete?.name}" ? Cette action est irréversible.`}
type="danger"
confirmText="Supprimer"
cancelText="Annuler"
isLoading={isDeleting}
/>
{/* ============================================
TOAST NOTIFICATIONS
============================================ */}
{showToast && (
<Toast
message={toastMessage}
type={toastType}
duration={3000}
onClose={() => setShowToast(false)}
/>
)}
</div>
);
};
export default AdminProduct;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,993 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
MapPin,
Package,
Navigation,
CheckCircle,
Clock,
AlertTriangle,
Star,
User,
DollarSign,
X,
ExternalLink,
Copy,
} from "lucide-react";
import "./CabineDashboard.css";
import ItemsModal from "../../components/Items";
import SidebarCabine from "../../components/SidebarCabine";
import {
getCommandItems,
getDeliverymanLocationForCommand,
getAllDeliveryPersonsWithDetails,
getPenaltiesStats,
isCabineAuthenticated, // ⭐ AJOUT - Vérification authentification
} from "../../api/api_cabine";
import {
getAllCommands,
getCommandCountCompleted,
getCommandCountByStatus,
} from "../../api/api_admin";
interface OrderItem {
id: number;
command_id: number;
product_id: number;
produit: string;
quantite: number;
prix: number;
status: string;
created_at: string;
client_nom?: string;
client_prenom?: string;
client_telephone?: string;
client_username?: string;
command_address?: string;
delivery_address?: string;
livreur_assign?: string;
total_prix?: number;
}
interface DeliveryOrder {
id: number;
orderNumber: string;
clientName: string;
clientPhone: string;
clientPoints: number;
address: string;
status: "pending" | "in_transit" | "delivered";
amount: number;
items: number;
penalty: number;
}
interface ActiveDeliveryPerson {
id: number;
username: string;
nom?: string;
prenom?: string;
status: "available" | "busy" | "offline";
location: {
latitude: number;
longitude: number;
last_update: string;
is_recent: boolean;
};
stats: {
current_command: number | null;
queue_size: number;
};
}
interface DriverStats {
completedToday: number;
pendingDeliveries: number;
totalEarnings: number;
totalPenalties: number;
averageRating: number;
}
function DriverDashboard() {
const navigate = useNavigate();
const [stats, setStats] = useState<DriverStats>({
completedToday: 0,
pendingDeliveries: 0,
totalEarnings: 0,
totalPenalties: 0,
averageRating: 0,
});
const [orders, setOrders] = useState<DeliveryOrder[]>([]);
const [selectedOrder, setSelectedOrder] = useState<DeliveryOrder | null>(
null,
);
const [loading, setLoading] = useState(true);
const [showItemsModal, setShowItemsModal] = useState(false);
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
const [selectedOrderNumber, setSelectedOrderNumber] = useState<string>("");
const [activeDeliveryPersons, setActiveDeliveryPersons] = useState<
ActiveDeliveryPerson[]
>([]);
const [showLocationModal, setShowLocationModal] = useState(false);
const [deliverymanLocation, setDeliverymanLocation] = useState<any>(null);
// ============================================
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isCabineAuthenticated()) {
console.log(
"❌ [CabineDashboard] Cabine non authentifié, redirection vers /login-cabine/cabine",
);
navigate("/login-cabine/cabine", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ============================================
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
// ============================================
useEffect(() => {
const authInterval = setInterval(() => {
if (!isCabineAuthenticated()) {
console.log(
"❌ [CabineDashboard] Session cabine expirée, redirection vers /login-cabine/cabine",
);
navigate("/login-cabines/cabine", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// Fonction pour afficher les items d'une commande
const handleViewItems = async (orderId: number, orderNumber: string) => {
try {
console.log("📦 [VIEW_ITEMS] Récupération items:", orderId);
const result = await getCommandItems(orderId);
console.log("📦 [VIEW_ITEMS] Résultat complet:", result);
console.log("📦 [VIEW_ITEMS] Items:", result.items);
console.log("📦 [VIEW_ITEMS] Nombre items:", result.items?.length);
if (result.success) {
setOrderItems(result.items || []);
setSelectedOrderNumber(orderNumber);
setShowItemsModal(true);
console.log("✅ [VIEW_ITEMS] Items récupérés:", result.items);
} else {
console.error("❌ [VIEW_ITEMS] Échec:", result);
alert("Erreur: " + "Impossible de récupérer les items");
}
} catch (error) {
console.error("❌ [VIEW_ITEMS] Erreur:", error);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [CabineDashboard] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
return;
}
alert("Erreur lors de la récupération des items");
}
};
// Fonction pour voir la position du livreur
const handleViewDeliverymanLocation = async (orderId: number) => {
try {
console.log(
"📍 [VIEW_LOCATION] Récupération position livreur:",
orderId,
);
const result = await getDeliverymanLocationForCommand(orderId);
if (result.success && result.data) {
setDeliverymanLocation(result.data);
setShowLocationModal(true);
} else {
alert(result.message || "Position non disponible");
}
} catch (error) {
console.error("❌ [VIEW_LOCATION] Erreur:", error);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [CabineDashboard] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
return;
}
alert("Erreur lors de la récupération de la position");
}
};
const copyCoordinates = () => {
if (deliverymanLocation) {
const coords = `${deliverymanLocation.deliveryman.location.latitude}, ${deliverymanLocation.deliveryman.location.longitude}`;
navigator.clipboard.writeText(coords);
alert("Coordonnées copiées !");
}
};
const openInGoogleMaps = () => {
if (deliverymanLocation) {
const { latitude, longitude } =
deliverymanLocation.deliveryman.location;
window.open(
`https://www.google.com/maps?q=${latitude},${longitude}`,
"_blank",
);
}
};
const openInWaze = () => {
if (deliverymanLocation) {
const { latitude, longitude } =
deliverymanLocation.deliveryman.location;
window.open(
`https://waze.com/ul?ll=${latitude},${longitude}&navigate=yes`,
"_blank",
);
}
};
useEffect(() => {
const fetchDriverData = async () => {
// ✅ Vérifier l'auth avant de charger les données
if (!isCabineAuthenticated()) {
console.log("❌ [fetchDriverData] Cabine non authentifié");
navigate("/login-cabine/cabine", { replace: true });
return;
}
try {
console.log("📊 [CABINE_DASHBOARD] Chargement des données...");
// ✅ 1. Récupérer les COUNTS directement
const [completedCount, pendingCount] = await Promise.all([
getCommandCountCompleted(),
getCommandCountByStatus(),
]);
console.log("📊 [CABINE_DASHBOARD] Counts récupérés:", {
completedCount,
pendingCount,
});
// ✅ 2. Récupérer les statistiques de pénalités
let totalPenaltiesFromAPI = 0;
try {
const penaltiesResult = await getPenaltiesStats();
console.log(
"⚠️ [CABINE_DASHBOARD] Statistiques pénalités:",
penaltiesResult,
);
if (penaltiesResult.success && penaltiesResult.data) {
totalPenaltiesFromAPI =
penaltiesResult.data.total_penalties || 0;
console.log(
"✅ [CABINE_DASHBOARD] Total pénalités récupéré:",
totalPenaltiesFromAPI,
);
}
} catch (error) {
console.error(
"❌ [CABINE_DASHBOARD] Erreur récupération stats pénalités:",
error,
);
}
// ✅ 3. Récupérer les commandes détaillées
const commandsResult = await getAllCommands();
console.log(
"📦 [CABINE_DASHBOARD] Commandes récupérées:",
commandsResult,
);
let transformedOrders: DeliveryOrder[] = [];
let totalEarnings = 0;
if (
commandsResult.success &&
commandsResult.commands &&
Array.isArray(commandsResult.commands)
) {
transformedOrders = await Promise.all(
commandsResult.commands.map(async (cmd: any) => {
let itemsCount = 0;
let clientInfo: any = null;
try {
const itemsResult = await getCommandItems(
cmd.id,
);
itemsCount = itemsResult.count || 0;
clientInfo = itemsResult.client_info;
} catch (error) {
console.warn(
`⚠️ Impossible de récupérer items pour CMD ${cmd.id}`,
);
}
return {
id: cmd.id,
orderNumber: `CMD-${String(cmd.id).padStart(3, "0")}`,
clientName:
clientInfo?.username || cmd.username,
clientPhone: clientInfo?.telephone || "N/A",
clientPoints: clientInfo?.point || 0,
address: cmd.adresse,
status:
cmd.status === "en_route"
? ("in_transit" as const)
: cmd.status === "livre" ||
cmd.status === "approved"
? ("delivered" as const)
: ("pending" as const),
amount: cmd.total_prix,
items: itemsCount,
penalty: clientInfo?.amende || 0,
};
}),
);
setOrders(transformedOrders);
totalEarnings = transformedOrders
.filter((o) => o.status === "delivered")
.reduce((sum, o) => sum + o.amount, 0);
}
// ✅ 4. Utiliser les COUNTS récupérés
const totalPenalties = totalPenaltiesFromAPI;
setStats({
completedToday: completedCount,
pendingDeliveries: pendingCount,
totalEarnings,
totalPenalties,
averageRating: 4.8,
});
console.log("✅ [CABINE_DASHBOARD] Stats finales:", {
completedToday: completedCount,
pendingDeliveries: pendingCount,
totalEarnings,
totalPenalties,
});
// ✅ 5. Récupérer les livreurs
let deliveryPersonsResult;
try {
deliveryPersonsResult =
await getAllDeliveryPersonsWithDetails();
console.log(
"👥 [CABINE_DASHBOARD] Livreurs récupérés:",
deliveryPersonsResult,
);
} catch (error) {
console.error(
"❌ [CABINE_DASHBOARD] Erreur récupération livreurs:",
error,
);
deliveryPersonsResult = {
success: false,
deliveryPersons: [],
count: 0,
};
}
if (
deliveryPersonsResult.success &&
deliveryPersonsResult.deliveryPersons &&
deliveryPersonsResult.deliveryPersons.length > 0
) {
const allPersons =
deliveryPersonsResult.deliveryPersons.map(
(person: any) => ({
id: person.id,
username: person.username,
nom: person.nom || "",
prenom: person.prenom || "",
status: person.status,
location: person.location,
stats: {
current_command:
person.stats?.current_command || null,
queue_size: person.stats?.queue_size || 0,
},
}),
);
console.log(
"✅ [CABINE_DASHBOARD] Tous les livreurs:",
allPersons,
);
setActiveDeliveryPersons(allPersons);
} else {
setActiveDeliveryPersons([]);
}
} catch (error) {
console.error(
"❌ [CABINE_DASHBOARD] Erreur chargement données:",
error,
);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log(
"🔓 [CabineDashboard] Token invalide - Redirection",
);
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
}
} finally {
setLoading(false);
}
};
fetchDriverData();
}, [navigate]);
const getStatusColor = (status: string) => {
switch (status) {
case "delivered":
return "green";
case "in_transit":
return "blue";
case "pending":
return "orange";
case "available":
return "green";
case "busy":
return "blue";
case "offline":
return "gray";
default:
return "gray";
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case "delivered":
return "Livrée";
case "in_transit":
return "En cours";
case "pending":
return "En attente";
case "available":
return "En ligne";
case "busy":
return "Occupé";
case "offline":
return "Hors ligne";
default:
return "Inconnu";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "available":
return <CheckCircle size={16} />;
case "busy":
return <Navigation size={16} />;
case "offline":
return <Clock size={16} />;
default:
return <AlertTriangle size={16} />;
}
};
const formatTimeAgo = (dateString: string) => {
const seconds = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000,
);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}j`;
};
if (loading) {
return (
<div className="driver-dashboard-container">
<div className="loading-dashboard">
<p>Chargement de votre cabine...</p>
</div>
</div>
);
}
return (
<>
<SidebarCabine />
<div className="driver-dashboard-container">
<div className="dashboard-header">
<h1>Dashboard Cabine</h1>
<p className="dashboard-subtitle">
Gestion de vos livraisons en temps réel
</p>
</div>
{/* Stats Cards */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon red">
<AlertTriangle size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Amendes</p>
<h3 className="stat-value">
{stats.totalPenalties.toFixed(2)}
</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon purple">
<CheckCircle size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Total Complétées</p>
<h3 className="stat-value">
{stats.completedToday}
</h3>
</div>
</div>
</div>
{/* Orders List */}
<div className="orders-section">
<h2>
<Package size={24} />
Mes Commandes ({orders.length})
</h2>
<div className="orders-list">
{orders.map((order) => (
<div
key={order.id}
className={`order-card ${selectedOrder?.id === order.id ? "selected" : ""}`}
onClick={() => setSelectedOrder(order)}
>
<div className="order-header">
<div className="order-number2">
<Package size={20} />
<span>{order.orderNumber}</span>
</div>
<div
className={`order-status ${getStatusColor(order.status)}`}
>
{order.status === "delivered" && (
<CheckCircle size={16} />
)}
{order.status === "in_transit" && (
<Navigation size={16} />
)}
{order.status === "pending" && (
<Clock size={16} />
)}
<span>
{getStatusLabel(order.status)}
</span>
</div>
</div>
<div className="order-client">
<div className="client-info">
<User size={18} />
<div>
<strong>{order.clientName}</strong>
<span className="client-points">
<Star size={14} />
{order.clientPoints} points
</span>
</div>
</div>
<a
href={`tel:${order.clientPhone}`}
className="phone-number"
>
{order.clientPhone}
</a>
</div>
<div className="order-address">
<MapPin size={18} />
<div>
<p>{order.address}</p>
</div>
</div>
<div className="order-details">
<div className="order-amount">
<DollarSign size={18} />
<span>
{order.amount.toFixed(2)} {" "}
{order.items} articles
</span>
</div>
{order.penalty > 0 && (
<div className="order-penalty">
<AlertTriangle size={18} />
<span>
Amende:{" "}
{order.penalty.toFixed(2)}
</span>
</div>
)}
</div>
<div className="order-actions">
<button
className="btn-secondary"
onClick={(e) => {
e.stopPropagation();
handleViewItems(
order.id,
order.orderNumber,
);
}}
>
<Package size={18} />
Voir items ({order.items})
</button>
{order.status === "in_transit" && (
<button
className="btn-secondary"
onClick={(e) => {
e.stopPropagation();
handleViewDeliverymanLocation(
order.id,
);
}}
>
<MapPin size={18} />
Position livreur
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* Items Modal */}
<ItemsModal
isOpen={showItemsModal}
onClose={() => setShowItemsModal(false)}
items={orderItems}
commandNumber={selectedOrderNumber}
/>
{/* Location Modal */}
{showLocationModal && deliverymanLocation && (
<div
className="modal-overlay"
onClick={() => setShowLocationModal(false)}
>
<div
className="location-modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="location-modal-header">
<div className="location-header-title">
<Navigation
size={24}
className="pulse-icon"
/>
<h3>Position du Livreur</h3>
</div>
<button
className="location-close-btn"
onClick={() => setShowLocationModal(false)}
>
<X size={20} />
</button>
</div>
<div className="location-modal-body">
<div className="location-info-card">
<div className="location-info-header">
<User size={20} />
<span>Informations Livreur</span>
</div>
<div className="location-info-content">
<div className="location-info-row">
<span className="location-label">
Nom:
</span>
<span className="location-value">
{
deliverymanLocation
.deliveryman.prenom
}{" "}
{
deliverymanLocation
.deliveryman.nom
}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Username:
</span>
<span className="location-value">
{
deliverymanLocation
.deliveryman.username
}
</span>
</div>
</div>
</div>
<div className="location-coords-card">
<div className="location-coords-header">
<MapPin size={20} />
<span>Coordonnées GPS</span>
</div>
<div className="location-coords-content">
<div className="location-coord-row">
<span className="location-coord-label">
Latitude:
</span>
<span className="location-coord-value">
{deliverymanLocation.deliveryman.location.latitude.toFixed(
6,
)}
°
</span>
</div>
<div className="location-coord-row">
<span className="location-coord-label">
Longitude:
</span>
<span className="location-coord-value">
{deliverymanLocation.deliveryman.location.longitude.toFixed(
6,
)}
°
</span>
</div>
</div>
</div>
<div className="location-update-card">
<div className="location-update-header">
<Clock size={20} />
<span>Dernière Mise à Jour</span>
</div>
<div className="location-update-content">
<div className="location-update-badge">
<CheckCircle size={16} />
<span>
Il y a{" "}
{
deliverymanLocation
.deliveryman.location
.last_update_ago
}
s
</span>
</div>
<div className="location-update-time">
{new Date(
deliverymanLocation.deliveryman
.location.last_update,
).toLocaleString("fr-FR")}
</div>
</div>
</div>
<div className="location-modal-actions">
<button
className="location-btn location-btn-primary"
onClick={openInGoogleMaps}
>
<ExternalLink size={18} />
Google Maps
</button>
<button
className="location-btn location-btn-waze"
onClick={openInWaze}
>
<Navigation size={18} />
Waze
</button>
<button
className="location-btn location-btn-secondary"
onClick={copyCoordinates}
>
<Copy size={18} />
Copier
</button>
</div>
</div>
</div>
</div>
)}
{/* Delivery Persons Section */}
<div className="orders-section">
<h2>
<Navigation size={24} />
Livreurs ({activeDeliveryPersons.length})
</h2>
{activeDeliveryPersons.length > 0 ? (
<div className="orders-list">
{activeDeliveryPersons.map((person) => (
<div key={person.id} className="order-card">
<div className="order-header">
<div className="order-number2">
<User size={20} />
<span>
{person.prenom && person.nom
? `${person.prenom} ${person.nom}`
: person.username}
</span>
</div>
<div
className={`order-status ${getStatusColor(person.status)}`}
>
{getStatusIcon(person.status)}
<span>
{getStatusLabel(person.status)}
</span>
</div>
</div>
<div className="order-client">
<div className="client-info">
<MapPin size={18} />
<div>
<strong>Position GPS</strong>
<span className="client-points">
{person.location.latitude.toFixed(
6,
)}
°,{" "}
{person.location.longitude.toFixed(
6,
)}
°
</span>
</div>
</div>
<div
className={`update-status ${person.location.is_recent ? "recent" : "old"}`}
style={{
padding: "0.5rem",
borderRadius: "8px",
background: person.location
.is_recent
? "rgba(34, 197, 94, 0.1)"
: "rgba(239, 68, 68, 0.1)",
color: person.location.is_recent
? "#22c55e"
: "#ef4444",
fontSize: "0.875rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
{person.location.is_recent ? (
<CheckCircle size={16} />
) : (
<Clock size={16} />
)}
<span>
{formatTimeAgo(
person.location.last_update,
)}
</span>
</div>
</div>
{person.stats.current_command && (
<div className="order-details">
<div className="order-amount">
<Package size={18} />
<span>
Commande en cours: CMD-
{String(
person.stats
.current_command,
).padStart(3, "0")}
</span>
</div>
{person.stats.queue_size > 0 && (
<div
className="order-penalty"
style={{
color: "#3b82f6",
borderColor: "#3b82f6",
}}
>
<Clock size={18} />
<span>
File d'attente:{" "}
{
person.stats
.queue_size
}
</span>
</div>
)}
</div>
)}
<div className="order-actions">
<button
className="btn-secondary"
onClick={() =>
window.open(
`https://www.google.com/maps?q=${person.location.latitude},${person.location.longitude}`,
"_blank",
)
}
>
<MapPin size={18} />
Voir sur carte
</button>
<button
className="btn-secondary"
onClick={() => {
navigator.clipboard.writeText(
`${person.location.latitude}, ${person.location.longitude}`,
);
alert("Coordonnées copiées !");
}}
>
<Copy size={18} />
Copier coordonnées
</button>
</div>
</div>
))}
</div>
) : (
<div
className="empty-state"
style={{ padding: "2rem", textAlign: "center" }}
>
<Navigation
size={64}
style={{ opacity: 0.3, margin: "0 auto 1rem" }}
/>
<h3>Aucun livreur enregistré</h3>
<p>
Aucun livreur n'est actuellement dans le système
</p>
</div>
)}
</div>
</div>
</>
);
}
export default DriverDashboard;
@@ -1,887 +0,0 @@
/* ============================================
CABINE ALERTS PAGE - MODERN DARK THEME
============================================ */
.alerts-page-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1600px;
margin: 0 auto;
background: #0a0a0a;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (min-width: 769px) {
.alerts-page-container {
padding-top: 1rem;
}
}
/* ============================================
HEADER SECTION
============================================ */
.alerts-page-header {
margin-bottom: clamp(1.5rem, 4vw, 2rem);
text-align: center;
padding-top: 3rem;
}
.alerts-page-header h1 {
color: white;
font-size: clamp(2rem, 6vw, 3rem);
margin: 0 0 0.5rem 0;
font-weight: bold;
letter-spacing: -0.5px;
background: linear-gradient(to right, #ef4444, #dc2626);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.alerts-page-subtitle {
color: #888;
font-size: clamp(1rem, 3vw, 1.2rem);
margin: 0;
}
/* ============================================
STATS OVERVIEW
============================================ */
.alerts-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.alert-stat-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
align-items: center;
gap: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.alert-stat-card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(239, 68, 68, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.alert-stat-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(239, 68, 68, 0.3);
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.2);
transform: translateY(-4px);
}
.alert-stat-card:hover::before {
left: 100%;
}
.alert-stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.alert-stat-card:hover .alert-stat-icon {
transform: scale(1.1) rotate(5deg);
}
.alert-stat-icon.red {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
color: #ef4444;
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
}
.alert-stat-icon.orange {
background: linear-gradient(
135deg,
rgba(251, 146, 60, 0.2),
rgba(249, 115, 22, 0.1)
);
color: #fb923c;
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
}
.alert-stat-icon.green {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
color: #10b981;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
.alert-stat-icon.blue {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
}
.alert-stat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-stat-label {
color: #888;
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.alert-stat-value {
color: white;
font-size: clamp(1.8rem, 5vw, 2.2rem);
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
/* ============================================
FILTERS SECTION
============================================ */
.alerts-filters {
display: flex;
gap: 1rem;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.alerts-search {
flex: 1;
min-width: 250px;
position: relative;
}
.alerts-search input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: white;
font-size: 1rem;
transition: all 0.3s ease;
}
.alerts-search input:focus {
outline: none;
border-color: rgba(239, 68, 68, 0.5);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 100%
);
}
.alerts-search input::placeholder {
color: #666;
}
.alerts-search svg {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #888;
pointer-events: none;
}
.alerts-filter-buttons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.filter-btn-2 {
padding: 0.75rem 1.5rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #888;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.filter-btn-2:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 100%
);
border-color: rgba(239, 68, 68, 0.3);
color: white;
}
.filter-btn-2.active {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: rgba(239, 68, 68, 0.5);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
/* ============================================
ALERTS LIST
============================================ */
.alerts-section {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.alerts-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: clamp(1rem, 3vw, 1.5rem);
flex-wrap: wrap;
gap: 1rem;
}
.alerts-section-header h2 {
color: white;
font-size: clamp(1.5rem, 4vw, 2rem);
margin: 0;
font-weight: bold;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alerts-section-header h2 svg {
color: #ef4444;
}
.alerts-count-badge {
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-weight: 600;
font-size: 0.9rem;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ============================================
ALERT CARD
============================================ */
.alert-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.alert-card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(239, 68, 68, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.alert-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(239, 68, 68, 0.3);
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
}
.alert-card:hover::before {
left: 100%;
}
.alert-card.active {
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.alert-card.resolved {
opacity: 0.6;
border-color: rgba(16, 185, 129, 0.3);
}
.alert-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.alert-priority-indicator {
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-priority-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.alert-priority-icon.critical {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.3),
rgba(220, 38, 38, 0.2)
);
color: #ef4444;
animation: pulse-critical 2s ease-in-out infinite;
}
.alert-priority-icon.high {
background: linear-gradient(
135deg,
rgba(251, 146, 60, 0.2),
rgba(249, 115, 22, 0.1)
);
color: #fb923c;
}
.alert-priority-icon.medium {
background: linear-gradient(
135deg,
rgba(234, 179, 8, 0.2),
rgba(202, 138, 4, 0.1)
);
color: #eab308;
}
.alert-priority-icon.low {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
}
@keyframes pulse-critical {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
.alert-info {
flex: 1;
}
.alert-id {
color: #888;
font-size: 0.85rem;
margin: 0 0 0.25rem 0;
font-weight: 600;
}
.alert-title {
color: white;
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
font-weight: 700;
}
.alert-subtitle {
color: #888;
font-size: 0.9rem;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.alert-status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.alert-status-badge.active {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.alert-status-badge.resolved {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.alert-status-badge.investigating {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
border: 1px solid rgba(234, 179, 8, 0.3);
}
/* ============================================
ALERT DETAILS
============================================ */
.alert-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.alert-detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.alert-detail-item svg {
color: #3b82f6;
flex-shrink: 0;
}
.alert-detail-content {
flex: 1;
min-width: 0;
}
.alert-detail-label {
color: #888;
font-size: 0.8rem;
margin: 0 0 0.25rem 0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.alert-detail-value {
color: white;
font-size: 0.95rem;
margin: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================
ALERT LOCATION
============================================ */
.alert-location {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 12px;
margin-bottom: 1rem;
}
.alert-location svg {
color: #3b82f6;
flex-shrink: 0;
margin-top: 0.2rem;
}
.alert-location-content {
flex: 1;
}
.alert-location-content p {
color: white;
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.alert-coordinates {
display: flex;
align-items: center;
gap: 0.5rem;
color: #888;
font-size: 0.85rem;
font-family: monospace;
}
.alert-coordinates svg {
color: #3b82f6;
}
/* ============================================
ALERT ACTIONS
============================================ */
.alert-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.alert-btn {
flex: 1;
min-width: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.alert-btn-primary {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.alert-btn-primary:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
transform: translateY(-2px);
}
.alert-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
}
.alert-btn-success:hover {
background: linear-gradient(135deg, #059669, #047857);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
transform: translateY(-2px);
}
.alert-btn-secondary {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
}
.alert-btn-secondary:hover {
background: rgba(59, 130, 246, 0.25);
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-2px);
}
.alert-btn-outline {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #888;
}
.alert-btn-outline:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
/* ============================================
EMPTY STATE
============================================ */
.alerts-empty-state {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.alerts-empty-state svg {
color: #3b82f6;
opacity: 0.3;
margin: 0 auto 1.5rem;
}
.alerts-empty-state h3 {
color: white;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-weight: 700;
}
.alerts-empty-state p {
color: #888;
font-size: 1rem;
margin: 0;
}
/* ============================================
LOADING STATE
============================================ */
.alerts-loading {
text-align: center;
padding: 4rem 2rem;
}
.alerts-loading p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* ============================================
RESPONSIVE DESIGN
============================================ */
@media (max-width: 1024px) {
.alerts-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.alerts-filters {
flex-direction: column;
align-items: stretch;
}
.alerts-search {
width: 100%;
}
.alerts-filter-buttons {
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.alerts-page-container {
padding: 1rem;
}
.alerts-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.alert-stat-card {
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.alert-stat-icon {
width: 48px;
height: 48px;
}
.alert-stat-value {
font-size: 1.5rem;
}
.alert-card-header {
flex-direction: column;
align-items: flex-start;
}
.alert-details {
grid-template-columns: 1fr;
}
.alert-actions {
flex-direction: column;
width: 100%;
}
.alert-btn {
width: 100%;
}
}
@media (max-width: 640px) {
.alerts-page-header h1 {
font-size: 1.8rem;
flex-direction: column;
}
.alerts-stats-grid {
grid-template-columns: 1fr;
}
.alert-stat-card {
flex-direction: row;
align-items: center;
}
.alert-card {
padding: 1rem;
}
.alert-priority-icon {
width: 40px;
height: 40px;
}
.alert-title {
font-size: 1.1rem;
}
}
@media (max-width: 480px) {
.alerts-page-container {
padding: 0.75rem;
}
.filter-btn-2 {
padding: 0.65rem 1rem;
font-size: 0.85rem;
}
.alert-btn {
font-size: 0.9rem;
padding: 0.65rem 1rem;
}
}
@media (hover: none) {
.alert-stat-card:hover,
.alert-card:hover {
transform: none;
}
.alert-btn-primary:hover,
.alert-btn-success:hover,
.alert-btn-secondary:hover {
transform: none;
}
}
@@ -1,586 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
Clock,
CheckCircle,
User,
Shield,
Search,
Filter,
Calendar,
X,
Eye,
} from "lucide-react";
import "./CabineAlerts.css";
import SidebarCabine from "../../components/SidebarCabine";
import {
isCabineAuthenticated,
getAllAlerts,
getAlertDetails,
} from "../../api/api_cabine";
interface Alert {
id: number;
username: string;
status: string;
created_at: string;
updated_at: string;
}
interface AlertStats {
total: number;
active: number;
resolved: number;
}
function CabineAlerts() {
const navigate = useNavigate();
const [alerts, setAlerts] = useState<Alert[]>([]);
const [filteredAlerts, setFilteredAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "true" | "false">(
"true",
);
const [stats, setStats] = useState<AlertStats>({
total: 0,
active: 0,
resolved: 0,
});
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
// ============================================
// 🔐 VÉRIFICATION AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isCabineAuthenticated()) {
console.log("❌ [CabineAlerts] Non authentifié, redirection");
navigate("/login-cabine/cabine", { replace: true });
}
};
checkAuth();
const authInterval = setInterval(() => {
if (!isCabineAuthenticated()) {
console.log("❌ [CabineAlerts] Session expirée");
navigate("/login-cabine/cabine", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// ============================================
// 📊 CHARGEMENT DES ALERTES
// ============================================
useEffect(() => {
const fetchAlerts = async () => {
if (!isCabineAuthenticated()) {
navigate("/login-cabine/cabine", { replace: true });
return;
}
try {
console.log("🚨 [CABINE_ALERTS] Chargement des alertes...");
// ✅ CHANGEMENT: Utiliser getAllAlerts au lieu de getActiveAlerts
const result = await getAllAlerts();
if (result.success && result.alerts) {
console.log(
"✅ [CABINE_ALERTS] Alertes récupérées:",
result.alerts,
);
const alertsData = result.alerts.map((alert: any) => ({
id: alert.id,
username: alert.username,
status: alert.status,
created_at: alert.created_at,
updated_at: alert.updated_at,
}));
setAlerts(alertsData);
// Calculer les stats
const activeCount = alertsData.filter(
(a: Alert) => a.status === "true",
).length;
const resolvedCount = alertsData.filter(
(a: Alert) => a.status === "false",
).length;
setStats({
total: alertsData.length,
active: activeCount,
resolved: resolvedCount,
});
} else {
console.warn("⚠️ [CABINE_ALERTS] Aucune alerte trouvée");
setAlerts([]);
setFilteredAlerts([]);
setStats({
total: 0,
active: 0,
resolved: 0,
});
}
} catch (error) {
console.error("❌ [CABINE_ALERTS] Erreur chargement:", error);
if (error instanceof Error && error.message.includes("401")) {
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
}
} finally {
setLoading(false);
}
};
fetchAlerts();
// Rafraîchir toutes les 30 secondes
const interval = setInterval(fetchAlerts, 30000);
return () => clearInterval(interval);
}, [navigate]);
// ============================================
// 🔍 FILTRAGE DES ALERTES
// ============================================
useEffect(() => {
let filtered = [...alerts];
// Filtre par statut
if (statusFilter !== "all") {
filtered = filtered.filter(
(alert) => alert.status === statusFilter,
);
}
// Filtre par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((alert) =>
alert.username.toLowerCase().includes(query),
);
}
setFilteredAlerts(filtered);
}, [alerts, statusFilter, searchQuery]);
// ============================================
// 🎨 HELPERS
// ============================================
const determinePriority = (
alert: Alert,
): "critical" | "high" | "medium" | "low" => {
if (alert.status === "true") {
const createdAt = new Date(alert.created_at);
const now = new Date();
const durationMinutes =
(now.getTime() - createdAt.getTime()) / 1000 / 60;
if (durationMinutes > 30) return "critical";
if (durationMinutes > 15) return "high";
if (durationMinutes > 5) return "medium";
}
return "low";
};
const getStatusLabel = (status: string) => {
switch (status) {
case "true":
return "Active";
case "false":
return "Résolue";
default:
return "Inconnu";
}
};
const formatDuration = (createdAt: string) => {
const created = new Date(createdAt);
const now = new Date();
const duration = Math.floor((now.getTime() - created.getTime()) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const secs = duration % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatTimeAgo = (dateString: string) => {
const seconds = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000,
);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}j`;
};
const handleViewDetails = async (alertId: number) => {
try {
const result = await getAlertDetails(alertId);
if (result.success && result.alert) {
setSelectedAlert(result.alert as Alert);
setShowDetailsModal(true);
} else {
alert("Impossible de récupérer les détails");
}
} catch (error) {
console.error("❌ Erreur récupération détails:", error);
alert("Erreur lors de la récupération des détails");
}
};
// ============================================
// 🎨 RENDER
// ============================================
if (loading) {
return (
<div className="alerts-page-container">
<div className="alerts-loading">
<p>Chargement des alertes...</p>
</div>
</div>
);
}
return (
<>
<SidebarCabine />
<div className="alerts-page-container">
{/* Header */}
<div className="alerts-page-header">
<h1>
<AlertTriangle size={40} />
Gestion des Alertes
</h1>
<p className="alerts-page-subtitle">
Surveillance en temps réel des alertes police
</p>
</div>
{/* Stats Grid */}
<div className="alerts-stats-grid">
<div className="alert-stat-card">
<div className="alert-stat-icon red">
<AlertTriangle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Total Alertes</p>
<h3 className="alert-stat-value">{stats.total}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon orange">
<Shield size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Alertes Actives</p>
<h3 className="alert-stat-value">{stats.active}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon green">
<CheckCircle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Résolues</p>
<h3 className="alert-stat-value">
{stats.resolved}
</h3>
</div>
</div>
</div>
{/* Filters */}
<div className="alerts-filters">
<div className="alerts-search">
<Search size={20} />
<input
type="text"
placeholder="Rechercher par livreur..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="alerts-filter-buttons">
<button
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
onClick={() => setStatusFilter("all")}
>
<Filter size={16} />
Toutes ({alerts.length})
</button>
<button
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
onClick={() => setStatusFilter("true")}
>
<Shield size={16} />
Actives ({stats.active})
</button>
<button
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
onClick={() => setStatusFilter("false")}
>
<CheckCircle size={16} />
Résolues ({stats.resolved})
</button>
</div>
</div>
{/* Alerts List */}
<div className="alerts-section">
<div className="alerts-section-header">
<h2>
<Shield size={24} />
Liste des Alertes
</h2>
<div className="alerts-count-badge">
{filteredAlerts.length} alerte
{filteredAlerts.length > 1 ? "s" : ""}
</div>
</div>
{filteredAlerts.length > 0 ? (
<div className="alerts-list">
{filteredAlerts.map((alert) => {
const priority = determinePriority(alert);
return (
<div
key={alert.id}
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
onClick={() =>
handleViewDetails(alert.id)
}
>
<div className="alert-card-header">
<div className="alert-priority-indicator">
<div
className={`alert-priority-icon ${priority}`}
>
<AlertTriangle size={24} />
</div>
<div className="alert-info">
<p className="alert-id">
Alerte #{alert.id}
</p>
<h3 className="alert-title">
{alert.username}
</h3>
<p className="alert-subtitle">
<Clock size={14} />
Il y a{" "}
{formatTimeAgo(
alert.created_at,
)}
</p>
</div>
</div>
<div
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
>
{alert.status === "true" ? (
<AlertTriangle size={16} />
) : (
<CheckCircle size={16} />
)}
{getStatusLabel(alert.status)}
</div>
</div>
<div className="alert-details">
<div className="alert-detail-item">
<User size={18} />
<div className="alert-detail-content">
<p className="alert-detail-label">
Livreur
</p>
<p className="alert-detail-value">
{alert.username}
</p>
</div>
</div>
<div className="alert-detail-item">
<Clock size={18} />
<div className="alert-detail-content">
<p className="alert-detail-label">
Durée
</p>
<p className="alert-detail-value">
{formatDuration(
alert.created_at,
)}
</p>
</div>
</div>
</div>
<div className="alert-actions">
<button
className="alert-btn alert-btn-outline"
onClick={(e) => {
e.stopPropagation();
handleViewDetails(alert.id);
}}
>
<Eye size={18} />
Détails
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="alerts-empty-state">
<Shield size={64} />
<h3>Aucune alerte trouvée</h3>
<p>
{searchQuery
? "Essayez de modifier vos critères de recherche"
: statusFilter === "true"
? "Aucune alerte active actuellement"
: "Aucune alerte n'est actuellement enregistrée"}
</p>
</div>
)}
</div>
{/* Details Modal */}
{showDetailsModal && selectedAlert && (
<div
className="modal-overlay"
onClick={() => setShowDetailsModal(false)}
>
<div
className="location-modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="location-modal-header">
<div className="location-header-title">
<AlertTriangle
size={24}
className="pulse-icon"
/>
<h3>
Détails de l'Alerte #{selectedAlert.id}
</h3>
</div>
<button
className="location-close-btn"
onClick={() => setShowDetailsModal(false)}
>
<X size={20} />
</button>
</div>
<div className="location-modal-body">
<div className="location-info-card">
<div className="location-info-header">
<User size={20} />
<span>Informations Livreur</span>
</div>
<div className="location-info-content">
<div className="location-info-row">
<span className="location-label">
Username:
</span>
<span className="location-value">
{selectedAlert.username}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Statut:
</span>
<span className="location-value">
{getStatusLabel(
selectedAlert.status,
)}
</span>
</div>
</div>
</div>
<div className="location-update-card">
<div className="location-update-header">
<Calendar size={20} />
<span>Informations Temporelles</span>
</div>
<div className="location-update-content">
<div className="location-info-row">
<span className="location-label">
Créée le:
</span>
<span className="location-value">
{formatDate(
selectedAlert.created_at,
)}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Durée:
</span>
<span className="location-value">
{formatDuration(
selectedAlert.created_at,
)}
</span>
</div>
<div className="location-info-row">
<span className="location-label">
Dernière mise à jour:
</span>
<span className="location-value">
{formatDate(
selectedAlert.updated_at,
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</>
);
}
export default CabineAlerts;
@@ -1,714 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users,
Search,
Filter,
MapPin,
Clock,
Package,
TrendingUp,
CheckCircle,
AlertCircle,
XCircle,
ChevronDown,
RefreshCw,
Truck,
Eye,
User,
ExternalLink,
Copy,
X
} from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMapLocationDot,
faSpinner
} from '@fortawesome/free-solid-svg-icons';
import {
faApple,
faGoogle,
faWaze
} from '@fortawesome/free-brands-svg-icons';
import './CabineDeliverymen.css';
import SidebarCabine from '../../components/SidebarCabine';
import {
getAllDeliveryPersonsWithDetails,
isCabineAuthenticated // ⭐ AJOUT
} from '../../api/api_cabine';
import { getDeliveryPersonDetails, getDeliveryPersonMapLinks } from '../../api/api_admin';
import type {
DeliveryPerson,
DeliveryPersonsStats,
DeliveryPersonDetails,
MapLinksResponse
} from '../../api/api_admin_types';
type FilterStatus = 'all' | 'available' | 'busy' | 'offline';
function CabineDeliverymen() {
const navigate = useNavigate();
const [deliveryPersons, setDeliveryPersons] = useState<DeliveryPerson[]>([]);
const [filteredDeliveryPersons, setFilteredDeliveryPersons] = useState<DeliveryPerson[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all');
const [showFilters, setShowFilters] = useState(false);
const [selectedDeliveryPerson, setSelectedDeliveryPerson] = useState<DeliveryPerson | null>(null);
const [selectedDeliveryPersonDetails, setSelectedDeliveryPersonDetails] = useState<DeliveryPersonDetails | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [mapLinks, setMapLinks] = useState<MapLinksResponse | null>(null);
const [loadingMapLinks, setLoadingMapLinks] = useState(false);
const [loadingDetails, setLoadingDetails] = useState(false);
const [copiedCoords, setCopiedCoords] = useState(false);
const [stats, setStats] = useState<DeliveryPersonsStats>({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0
});
// ============================================
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isCabineAuthenticated()) {
console.log('❌ [CabineDeliverymen] Cabine non authentifié, redirection vers /login-cabine/cabine');
navigate('/login-cabine/cabine', { replace: true });
}
};
checkAuth();
}, [navigate]);
// ============================================
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
// ============================================
useEffect(() => {
const authInterval = setInterval(() => {
if (!isCabineAuthenticated()) {
console.log('❌ [CabineDeliverymen] Session cabine expirée, redirection vers /login-cabine/cabine');
navigate('/login-cabine/cabine', { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// Récupération des livreurs
useEffect(() => {
fetchDeliveryPersons();
}, []);
const fetchDeliveryPersons = async () => {
// ✅ Vérifier l'auth avant de charger
if (!isCabineAuthenticated()) {
console.log('❌ [fetchDeliveryPersons] Cabine non authentifié');
navigate('/login-cabine/cabine', { replace: true });
return;
}
setLoading(true);
try {
console.log('[DELIVERY_PERSONS] Récupération des livreurs...');
const result = await getAllDeliveryPersonsWithDetails();
if (!result.success) {
console.error('[DELIVERY_PERSONS] Erreur:', result);
setDeliveryPersons([]);
setFilteredDeliveryPersons([]);
setStats({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0
});
setLoading(false);
return;
}
console.log('[DELIVERY_PERSONS] Données reçues:', result);
setDeliveryPersons(result.deliveryPersons);
setFilteredDeliveryPersons(result.deliveryPersons);
setStats(result.stats);
console.log('[DELIVERY_PERSONS] Données chargées:', result.count, 'livreurs');
} catch (error) {
console.error('[DELIVERY_PERSONS] Erreur chargement:', error);
if (error instanceof Error && error.message.includes('401')) {
console.log('🔓 [CabineDeliverymen] Token invalide');
sessionStorage.removeItem('admin_token');
sessionStorage.removeItem('admin_username');
navigate('/login-cabine/cabine', { replace: true });
return;
}
setDeliveryPersons([]);
setFilteredDeliveryPersons([]);
setStats({
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0
});
} finally {
setLoading(false);
}
};
// Filtrage
useEffect(() => {
let filtered = deliveryPersons;
if (filterStatus !== 'all') {
filtered = filtered.filter(d => d.status === filterStatus);
}
if (searchTerm) {
filtered = filtered.filter(d =>
d.username.toLowerCase().includes(searchTerm.toLowerCase())
);
}
setFilteredDeliveryPersons(filtered);
}, [searchTerm, filterStatus, deliveryPersons]);
// Charger les détails complets
const loadDeliveryPersonDetails = async (username: string) => {
setLoadingDetails(true);
setSelectedDeliveryPersonDetails(null);
try {
console.log('[DELIVERY_DETAILS] Chargement détails pour:', username);
const details = await getDeliveryPersonDetails(username);
console.log('[DELIVERY_DETAILS] Détails reçus:', details);
setSelectedDeliveryPersonDetails(details);
} catch (error) {
console.error('[DELIVERY_DETAILS] Erreur:', error);
if (error instanceof Error && error.message.includes('401')) {
console.log('🔓 [CabineDeliverymen] Token invalide');
sessionStorage.removeItem('admin_token');
sessionStorage.removeItem('admin_username');
navigate('/login-cabine/cabine', { replace: true });
}
} finally {
setLoadingDetails(false);
}
};
// Charger les liens GPS
const loadMapLinks = async (username: string) => {
setLoadingMapLinks(true);
setMapLinks(null);
try {
console.log('[MAP_LINKS] Chargement pour:', username);
const result = await getDeliveryPersonMapLinks(username);
if (result.success) {
console.log('[MAP_LINKS] Liens chargés:', result);
setMapLinks(result);
} else {
console.error('[MAP_LINKS] Erreur:', result);
setMapLinks(result);
}
} catch (error) {
console.error('[MAP_LINKS] Erreur:', error);
if (error instanceof Error && error.message.includes('401')) {
console.log('🔓 [CabineDeliverymen] Token invalide');
sessionStorage.removeItem('admin_token');
sessionStorage.removeItem('admin_username');
navigate('/login-cabine/cabine', { replace: true });
}
} finally {
setLoadingMapLinks(false);
}
};
// Ouvrir modal avec détails
const handleViewDetails = async (person: DeliveryPerson) => {
setSelectedDeliveryPerson(person);
setShowDetailsModal(true);
setMapLinks(null);
setSelectedDeliveryPersonDetails(null);
setCopiedCoords(false);
await Promise.all([
loadDeliveryPersonDetails(person.username),
loadMapLinks(person.username)
]);
};
const handleRefresh = async () => {
setRefreshing(true);
await fetchDeliveryPersons();
setRefreshing(false);
};
const handleCopyCoordinates = () => {
if (mapLinks?.location) {
const coords = `${mapLinks.location.latitude}, ${mapLinks.location.longitude}`;
navigator.clipboard.writeText(coords);
setCopiedCoords(true);
setTimeout(() => setCopiedCoords(false), 2000);
}
};
const getStatusLabel = (status: string) => {
const labels = {
available: 'En ligne',
busy: 'Occupé',
offline: 'Hors ligne'
};
return labels[status as keyof typeof labels] || status;
};
const getStatusCount = (status: FilterStatus) => {
if (status === 'all') return stats.total;
return stats[status as keyof typeof stats] || 0;
};
const hasValidMapData = mapLinks?.success && mapLinks.map_links && mapLinks.location;
if (loading) {
return (
<>
<SidebarCabine />
<div className="cabine-deliverymen-container">
<div className="loading-deliverymen">
<p>Chargement des livreurs...</p>
</div>
</div>
</>
);
}
return (
<>
<SidebarCabine />
<div className="cabine-deliverymen-container">
{/* Header */}
<div className="orders-header">
<div className="header-content">
<h1>Gestion des Livreurs</h1>
<p className="header-subtitle">Suivi et supervision des livreurs</p>
</div>
<button
className="export-button"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw size={18} className={refreshing ? 'spin-animation' : ''} />
<span>Actualiser</span>
</button>
</div>
{/* Stats rapides */}
<div className="orders-stats">
<div className="stat-item">
<div className="stat-icon purple">
<Users size={20} />
</div>
<div className="stat-info">
<span className="stat-value">{stats.total}</span>
<span className="stat-label">Total Livreurs</span>
</div>
</div>
<div className="stat-item">
<div className="stat-icon green">
<CheckCircle size={20} />
</div>
<div className="stat-info">
<span className="stat-value">{stats.available}</span>
<span className="stat-label">Disponibles</span>
</div>
</div>
<div className="stat-item">
<div className="stat-icon orange">
<Truck size={20} />
</div>
<div className="stat-info">
<span className="stat-value">{stats.busy}</span>
<span className="stat-label">Occupés</span>
</div>
</div>
<div className="stat-item">
<div className="stat-icon red">
<XCircle size={20} />
</div>
<div className="stat-info">
<span className="stat-value">{stats.offline}</span>
<span className="stat-label">Hors Ligne</span>
</div>
</div>
</div>
{/* Contrôles */}
<div className="orders-controls">
<div className="search-bar">
<Search size={20} />
<input
type="text"
placeholder="Rechercher username ..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
className={`filter-toggle ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
<span>Filtres</span>
<ChevronDown size={16} className={showFilters ? 'rotated' : ''} />
</button>
</div>
{/* Filtres */}
{showFilters && (
<div className="filters-panel">
<div className="filter-group">
<label>Statut</label>
<div className="filter-buttons">
<button
className={filterStatus === 'all' ? 'active' : ''}
onClick={() => setFilterStatus('all')}
>
Tous ({getStatusCount('all')})
</button>
<button
className={filterStatus === 'available' ? 'active' : ''}
onClick={() => setFilterStatus('available')}
>
Disponibles ({getStatusCount('available')})
</button>
<button
className={filterStatus === 'busy' ? 'active' : ''}
onClick={() => setFilterStatus('busy')}
>
Occupés ({getStatusCount('busy')})
</button>
<button
className={filterStatus === 'offline' ? 'active' : ''}
onClick={() => setFilterStatus('offline')}
>
Hors ligne ({getStatusCount('offline')})
</button>
</div>
</div>
</div>
)}
{/* Liste des livreurs */}
<div className="orders-list">
{filteredDeliveryPersons.length === 0 ? (
<div className="empty-state">
<Users size={64} />
<h3>Aucun livreur trouvé</h3>
<p>Aucun livreur ne correspond à vos critères de recherche</p>
</div>
) : (
filteredDeliveryPersons.map(person => (
<div key={person.id} className="order-card">
<div className="order-header">
<div className={`order-status status-${person.status === 'available' ? 'livre' : person.status === 'busy' ? 'en_route' : 'cancelled'}`}>
{person.status === 'available' && <CheckCircle size={16} />}
{person.status === 'busy' && <Truck size={16} />}
{person.status === 'offline' && <XCircle size={16} />}
<span>{getStatusLabel(person.status)}</span>
</div>
</div>
<div className="order-content">
<div className="order-info-grid">
<div className="info-item">
<User size={16} />
<div className="info-details">
<span className="info-label">Username</span>
<span className="info-value">{person.username}</span>
</div>
</div>
<div className="info-item">
<Package size={16} />
<div className="info-details">
<span className="info-label">Livraisons totales</span>
<span className="info-value">{person.stats.total_deliveries}</span>
</div>
</div>
<div className="info-item">
<TrendingUp size={16} />
<div className="info-details">
<span className="info-label">Complétées aujourd'hui</span>
<span className="info-value">{person.stats.completed_today}</span>
</div>
</div>
<div className="info-item">
<Clock size={16} />
<div className="info-details">
<span className="info-label">File d'attente</span>
<span className="info-value">{person.stats.queue_size} commande(s)</span>
</div>
</div>
<div className="info-item">
<MapPin size={16} />
<div className="info-details">
<span className="info-label">Position GPS</span>
<span className="info-value">
{person.location.latitude.toFixed(4)}°, {person.location.longitude.toFixed(4)}°
</span>
</div>
</div>
</div>
{person.stats.current_command && (
<div className="order-items">
<span className="items-label">Commande en cours:</span>
<span className="item-tag">
CMD-{person.stats.current_command.toString().padStart(6, '0')}
</span>
</div>
)}
</div>
<div className="order-footer">
<button
className="view-details-button"
onClick={() => handleViewDetails(person)}
>
<Eye size={18} />
<span>Détails</span>
</button>
</div>
</div>
))
)}
</div>
{/* Modal détails - Code identique avec gestion erreurs 401 */}
{showDetailsModal && selectedDeliveryPerson && (
<>
<div className="modal-overlay" onClick={() => setShowDetailsModal(false)} />
<div className="order-details-modal">
<div className="modal-header">
<h2>Détails du livreur</h2>
<button
className="close-modal"
onClick={() => setShowDetailsModal(false)}
>
<X size={24} />
</button>
</div>
<div className="modal-content">
{loadingDetails ? (
<div className="loading-section">
<FontAwesomeIcon icon={faSpinner} size="2x" className="spin-animation" />
<span>Chargement des détails...</span>
</div>
) : selectedDeliveryPersonDetails ? (
<>
<div className="detail-section">
<h3>Informations personnelles</h3>
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">Username</span>
<span className="detail-value">{selectedDeliveryPersonDetails.username}</span>
</div>
<div className="detail-item">
<span className="detail-label">Rôle</span>
<span className="detail-value">{selectedDeliveryPersonDetails.role}</span>
</div>
{selectedDeliveryPersonDetails.location?.last_update && (
<div className="detail-item">
<span className="detail-label">Dernière mise à jour</span>
<span className="detail-value">
{new Date(selectedDeliveryPersonDetails.location.last_update * 1000).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</span>
</div>
)}
</div>
</div>
<div className="detail-section">
<h3>Statistiques détaillées</h3>
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">Total livraisons</span>
<span className="detail-value">{selectedDeliveryPersonDetails.total_deliveries}</span>
</div>
<div className="detail-item">
<span className="detail-label">Livraisons complétées</span>
<span className="detail-value">{selectedDeliveryPersonDetails.completed_deliveries}</span>
</div>
<div className="detail-item">
<span className="detail-label">Livraisons en attente</span>
<span className="detail-value">{selectedDeliveryPersonDetails.pending_deliveries}</span>
</div>
<div className="detail-item">
<span className="detail-label">File d'attente</span>
<span className="detail-value">{selectedDeliveryPersonDetails.queue_size} commande(s)</span>
</div>
{selectedDeliveryPersonDetails.current_command && (
<div className="detail-item">
<span className="detail-label">Commande en cours</span>
<span className="detail-value">
CMD-{selectedDeliveryPersonDetails.current_command.toString().padStart(6, '0')}
</span>
</div>
)}
</div>
</div>
{selectedDeliveryPersonDetails.location && (
<div className="detail-section">
<h3>Position GPS</h3>
<div className="gps-info">
<div className="gps-coordinates">
<div className="coordinate">
<span className="coord-label">Latitude:</span>
<span className="coord-value">
{selectedDeliveryPersonDetails.location.latitude.toFixed(6)}°
</span>
</div>
<div className="coordinate">
<span className="coord-label">Longitude:</span>
<span className="coord-value">
{selectedDeliveryPersonDetails.location.longitude.toFixed(6)}°
</span>
</div>
</div>
</div>
</div>
)}
</>
) : (
<div className="detail-section">
<div className="detail-item">
<span className="detail-label">Username</span>
<span className="detail-value">{selectedDeliveryPerson.username}</span>
</div>
</div>
)}
{/* Section Navigation GPS */}
<div className="detail-section">
<h3>
<FontAwesomeIcon icon={faMapLocationDot} /> Navigation GPS
</h3>
{loadingMapLinks ? (
<div className="map-links-loading">
<FontAwesomeIcon icon={faSpinner} size="2x" className="spin-animation" />
<span>Chargement des liens GPS...</span>
</div>
) : hasValidMapData && mapLinks.map_links && mapLinks.location ? (
(() => {
const links = mapLinks.map_links;
const location = mapLinks.location;
return (
<>
<div className="map-links-grid">
<a href={links.google_maps} target="_blank" rel="noopener noreferrer" className="map-link-button google">
<FontAwesomeIcon icon={faGoogle} className="map-icon" />
<div className="link-content">
<span className="link-title">Google Maps</span>
<span className="link-subtitle">Voir la position</span>
</div>
<ExternalLink size={16} className="external-icon" />
</a>
<a href={links.waze} target="_blank" rel="noopener noreferrer" className="map-link-button waze">
<FontAwesomeIcon icon={faWaze} className="map-icon" />
<div className="link-content">
<span className="link-title">Waze</span>
<span className="link-subtitle">Navigation GPS</span>
</div>
<ExternalLink size={16} className="external-icon" />
</a>
<a href={links.apple_maps} target="_blank" rel="noopener noreferrer" className="map-link-button apple">
<FontAwesomeIcon icon={faApple} className="map-icon" />
<div className="link-content">
<span className="link-title">Apple Maps</span>
<span className="link-subtitle">Ouvrir dans Plans</span>
</div>
<ExternalLink size={16} className="external-icon" />
</a>
<a href={links.openstreetmap} target="_blank" rel="noopener noreferrer" className="map-link-button osm">
<MapPin size={24} className="map-icon" />
<div className="link-content">
<span className="link-title">OpenStreetMap</span>
<span className="link-subtitle">Carte open source</span>
</div>
<ExternalLink size={16} className="external-icon" />
</a>
</div>
<div className="coordinates-copy">
<button onClick={handleCopyCoordinates} className={`copy-coords-button ${copiedCoords ? 'copied' : ''}`}>
<Copy size={16} />
<span>{copiedCoords ? 'Coordonnées copiées !' : 'Copier coordonnées'}</span>
</button>
<span className="coords-display">
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
</span>
</div>
</>
);
})()
) : (
<div className="map-links-error">
<AlertCircle size={48} />
<h4>Position GPS non disponible</h4>
<p>
{mapLinks?.message || "Le livreur n'a pas encore partagé sa position GPS ou celle-ci est trop ancienne."}
</p>
</div>
)}
</div>
</div>
<div className="modal-actions">
<button
className="action-button secondary"
onClick={() => setShowDetailsModal(false)}
>
<X size={18} />
<span>Fermer</span>
</button>
</div>
</div>
</>
)}
</div>
</>
);
}
export default CabineDeliverymen;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,939 +0,0 @@
/* ============================================ */
/* USERS MANAGEMENT - STYLE MODERNE CABINE */
/* ============================================ */
.users-management-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1600px;
margin: 0 auto;
background: #0a0a0a;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (min-width: 769px) {
.users-management-container {
padding-top: 1rem;
}
}
/* ============================================ */
/* FILTERS SECTION */
/* ============================================ */
.filters-section {
display: flex;
gap: 1rem;
margin-bottom: clamp(2rem, 5vw, 3rem);
flex-wrap: wrap;
}
.search-bar {
flex: 1;
min-width: 280px;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
backdrop-filter: blur(8px);
transition: all 0.3s ease;
}
.search-bar:focus-within {
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.2);
}
.search-bar svg {
color: #3b82f6;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 1rem;
font-weight: 500;
}
.search-bar input::placeholder {
color: #888;
}
.role-filters {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.filter-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
color: #888;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.filter-btn:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(59, 130, 246, 0.3);
color: white;
transform: translateY(-2px);
}
.filter-btn.active {
background: #5b21b6;
border-color: #4c1d95;
color: white;
box-shadow: 0 4px 16px #7c3aed;
}
.filter-btn svg {
flex-shrink: 0;
}
/* ============================================ */
/* USERS SECTION */
/* ============================================ */
.users-section {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.users-section h2 {
color: white;
font-size: clamp(1.5rem, 4vw, 2rem);
margin: 0 0 clamp(1rem, 3vw, 1.5rem) 0;
font-weight: bold;
display: flex;
align-items: center;
gap: 0.75rem;
}
.users-section h2 svg {
color: #3b82f6;
}
.users-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ============================================ */
/* USER CARD */
/* ============================================ */
.user-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.user-card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(59, 130, 246, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.user-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.2);
transform: translateY(-2px);
}
.user-card:hover::before {
left: 100%;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.user-info-main {
display: flex;
align-items: center;
gap: 1rem;
}
.user-avatar {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
transition: transform 0.3s ease;
}
.user-card:hover .user-avatar {
transform: scale(1.1) rotate(5deg);
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.user-username {
color: white;
font-size: 1.1rem;
font-weight: 700;
}
.user-id {
color: #888;
font-size: 0.85rem;
font-family: monospace;
}
.user-role-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.user-role-badge.red {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.user-role-badge.purple {
background: rgba(124, 58, 237, 0.15);
color: #7c3aed;
border: 1px solid rgba(124, 58, 237, 0.3);
}
.user-role-badge.blue {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.user-role-badge.gray {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
border: 1px solid rgba(156, 163, 175, 0.3);
}
/* ============================================ */
/* USER ACTIONS */
/* ============================================ */
.user-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-edit,
.btn-delete {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
min-width: fit-content;
}
.btn-edit {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
}
.btn-edit:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
transform: translateY(-2px);
}
.btn-delete {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.btn-delete:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
transform: translateY(-2px);
}
/* ============================================ */
/* EDIT MODAL */
/* ============================================ */
.edit-modal-content {
background: linear-gradient(
135deg,
rgba(20, 20, 20, 0.98) 0%,
rgba(15, 15, 15, 0.95) 100%
);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 24px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(59, 130, 246, 0.2);
animation: slideUp 0.3s ease;
}
.edit-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.1) 0%,
transparent 100%
);
}
.modal-header-title {
display: flex;
align-items: center;
gap: 1rem;
}
.modal-header-title svg {
color: #3b82f6;
}
.modal-header-title h3 {
color: white;
font-size: 1.5rem;
margin: 0;
font-weight: 700;
}
.modal-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #888;
cursor: pointer;
transition: all 0.3s ease;
}
.modal-close-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
transform: scale(1.1);
}
.edit-modal-body {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ============================================ */
/* FORM GROUPS */
/* ============================================ */
.form-group2 {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-group2 label {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
font-weight: 600;
font-size: 0.95rem;
}
.form-group2 label svg {
color: #3b82f6;
}
.form-group2 input,
.form-group2 select {
width: 100%;
padding: 0.875rem 1.25rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: white;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s ease;
}
.form-group2 input:focus,
.form-group2 select:focus {
outline: none;
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.2);
background: rgba(255, 255, 255, 0.05);
}
.form-group2 input::placeholder {
color: #666;
}
.form-group2 select {
cursor: pointer;
}
.form-group2 select option {
background: #1a1a1a;
color: white;
}
.password-input-group {
position: relative;
display: flex;
align-items: center;
}
.password-input-group input {
flex: 1;
padding-right: 3rem;
}
.password-toggle {
position: absolute;
right: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
color: #888;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 6px;
}
.password-toggle:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
/* ============================================ */
/* MODAL ACTIONS */
/* ============================================ */
.modal-actions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-cancel,
.btn-save {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
border: none;
border-radius: 12px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-cancel {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
margin-left: -2px;
}
.btn-cancel:hover {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
}
.btn-save {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
.btn-save:hover {
background: linear-gradient(135deg, #059669, #047857);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.4);
transform: translateY(-2px);
}
/* ============================================ */
/* EMPTY STATE */
/* ============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: #888;
gap: 1rem;
}
.empty-state h3 {
color: white;
font-size: 1.5rem;
margin: 0;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
/* ============================================ */
/* LOADING */
/* ============================================ */
.loading-users {
text-align: center;
padding: 4rem 2rem;
}
.loading-users p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* ============================================ */
/* RESPONSIVE */
/* ============================================ */
@media (max-width: 1024px) {
.filters-section {
flex-direction: column;
}
.search-bar {
min-width: 100%;
}
.role-filters {
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.users-management-container {
padding: 1rem;
}
.user-header {
flex-direction: column;
align-items: flex-start;
}
.user-role-badge {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
.user-actions {
flex-direction: column;
width: 100%;
}
.btn-edit,
.btn-delete {
width: 100%;
}
.edit-modal-content {
max-width: 95%;
}
.edit-modal-header,
.edit-modal-body {
padding: 1.5rem;
}
.modal-header-title h3 {
font-size: 1.25rem;
}
.modal-actions {
flex-direction: column;
}
.btn-cancel,
.btn-save {
width: 100%;
}
}
@media (max-width: 480px) {
.filter-btn {
padding: 10px 13px;
font-size: 0.85rem;
}
.user-avatar {
width: 48px;
height: 48px;
}
.user-username {
font-size: 1rem;
}
.btn-edit,
.btn-delete {
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
.edit-modal-header,
.edit-modal-body {
padding: 1rem;
}
.modal-header-title h3 {
font-size: 1.1rem;
}
.form-group2 input,
.form-group2 select {
padding: 0.75rem 1rem;
font-size: 0.95rem;
}
}
@media (hover: none) {
.user-card:hover {
transform: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.btn-edit:hover,
.btn-delete:hover,
.btn-cancel:hover,
.btn-save:hover {
transform: none;
}
}
/* Styles supplémentaires pour les clients */
.clients-section {
margin-top: 3rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.search-bar.compact {
max-width: 400px;
margin: 0;
}
.client-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
}
.user-avatar.client {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
}
.user-role-badge.green {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(16, 185, 129, 0.1)
);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.client-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
margin: 1rem 0;
}
.info-item {
display: flex;
align-items: center;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item svg {
color: #10b981;
flex-shrink: 0;
}
.info-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-view {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.btn-view:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
background: linear-gradient(135deg, #059669, #047857);
}
.btn-view:active {
transform: translateY(0);
}
/* Client Details Modal */
.client-modal {
max-width: 700px;
}
.client-detail-section {
margin-bottom: 2rem;
}
.client-detail-section h4 {
color: #10b981;
font-size: 1.1rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(16, 185, 129, 0.3);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.detail-item.full {
grid-column: 1 / -1;
}
.detail-item label {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-item label svg {
color: #10b981;
}
.detail-item p {
color: rgba(255, 255, 255, 0.95);
font-size: 1rem;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border-left: 3px solid #10b981;
margin: 0;
word-break: break-word;
}
/* Stat card green variant */
.stat-icon.green {
background: linear-gradient(135deg, #10b981, #059669);
}
/* Responsive */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
align-items: stretch;
}
.search-bar.compact {
max-width: 100%;
}
.client-info-grid {
grid-template-columns: 1fr;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
/* Animation pour les cartes clients */
@keyframes slideInClient {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.client-card {
animation: slideInClient 0.4s ease-out;
}
/* Hover effect pour les info items */
.info-item:hover svg {
transform: scale(1.2);
transition: transform 0.2s ease;
}
@@ -1,876 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
User,
Shield,
Truck,
Store,
Search,
XCircle,
Phone,
TrendingUp,
AlertCircle,
Filter,
Eye,
Leaf,
Wind,
} from "lucide-react";
import "./UserManagement.css";
import SidebarCabine from "../../components/SidebarCabine";
import { getAllUsers, getAllClients } from "../../api/api_admin";
import { isCabineAuthenticated } from "../../api/api_cabine"; // ⭐ AJOUT
import type { AdminResponse } from "../../api/api_admin_types";
import type { ClientResponse } from "../../api/api_admin";
// ============================================
// TYPES & INTERFACES
// ============================================
interface UserStats {
totalUsers: number;
totalCabines: number;
totalLivreurs: number;
}
type UserRole = "admin" | "cabine" | "livreur" | "client";
type RoleFilter = "all" | "cabine" | "livreur";
interface RoleConfig {
color: string;
label: string;
icon: React.ComponentType<{ size: number }>;
}
// ============================================
// CONSTANTS
// ============================================
const ROLE_CONFIG: Record<UserRole, RoleConfig> = {
admin: { color: "red", label: "Administrateur", icon: Shield },
cabine: { color: "purple", label: "Cabine", icon: Store },
livreur: { color: "blue", label: "Livreur", icon: Truck },
client: { color: "green", label: "Client", icon: User },
};
// ============================================
// MAIN COMPONENT
// ============================================
function UsersManagement() {
const navigate = useNavigate();
// État des utilisateurs
const [users, setUsers] = useState<AdminResponse[]>([]);
const [filteredUsers, setFilteredUsers] = useState<AdminResponse[]>([]);
const [stats, setStats] = useState<UserStats>({
totalUsers: 0,
totalCabines: 0,
totalLivreurs: 0,
});
// État des clients
const [clients, setClients] = useState<ClientResponse[]>([]);
const [filteredClients, setFilteredClients] = useState<ClientResponse[]>(
[],
);
const [totalClients, setTotalClients] = useState(0);
// État des filtres et recherche
const [searchTerm, setSearchTerm] = useState("");
const [clientSearchTerm, setClientSearchTerm] = useState("");
const [roleFilter, setRoleFilter] = useState<RoleFilter>("all");
// État du modal
const [selectedClient, setSelectedClient] = useState<ClientResponse | null>(
null,
);
const [showClientModal, setShowClientModal] = useState(false);
// État de chargement
const [loading, setLoading] = useState(true);
// ============================================
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isCabineAuthenticated()) {
console.log(
"❌ [UserManagement] Cabine non authentifié, redirection vers /login-cabine/cabine",
);
navigate("/login-cabine/cabine", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ============================================
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
// ============================================
useEffect(() => {
const authInterval = setInterval(() => {
if (!isCabineAuthenticated()) {
console.log(
"❌ [UserManagement] Session cabine expirée, redirection vers /login-cabine/cabine",
);
navigate("/login-cabine/cabine", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// ============================================
// FETCH DATA
// ============================================
const fetchUsers = useCallback(async () => {
// ✅ Vérifier l'auth avant de charger
if (!isCabineAuthenticated()) {
console.log("❌ [fetchUsers] Cabine non authentifié");
navigate("/login-cabine/cabine", { replace: true });
return;
}
try {
console.log("👥 [USERS_MANAGEMENT] Chargement des utilisateurs...");
setLoading(true);
const usersData = await getAllUsers();
console.log(
"✅ [USERS_MANAGEMENT] Utilisateurs récupérés:",
usersData,
);
// Exclure les admins de la liste
const filteredUsersData = usersData.filter(
(u) => u.role !== "admin",
);
setUsers(filteredUsersData);
// Calculer les statistiques
const newStats: UserStats = {
totalUsers: filteredUsersData.length,
totalCabines: filteredUsersData.filter(
(u) => u.role === "cabine",
).length,
totalLivreurs: filteredUsersData.filter(
(u) => u.role === "livreur",
).length,
};
setStats(newStats);
console.log("📊 [USERS_MANAGEMENT] Stats:", newStats);
} catch (error) {
console.error("❌ [USERS_MANAGEMENT] Erreur chargement:", error);
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [UserManagement] Token invalide");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
return;
}
alert("Erreur lors du chargement des utilisateurs");
} finally {
setLoading(false);
}
}, [navigate]);
const fetchClients = useCallback(async () => {
// ✅ Vérifier l'auth avant de charger
if (!isCabineAuthenticated()) {
console.log("❌ [fetchClients] Cabine non authentifié");
navigate("/login-cabine/cabine", { replace: true });
return;
}
try {
console.log("👥 [CLIENTS_MANAGEMENT] Chargement des clients...");
setLoading(true);
const clientData = await getAllClients();
console.log(
"✅ [CLIENTS_MANAGEMENT] Clients récupérés:",
clientData,
);
if (clientData && clientData.length > 0) {
const firstClient = clientData[0];
console.log("🔍 [DEBUG] Premier client:", {
username: firstClient.username,
points_weed: firstClient.point,
points_zipette: firstClient.points_zipette,
});
}
setClients(clientData);
setTotalClients(clientData.length);
} catch (error) {
console.error("❌ [CLIENTS_MANAGEMENT] Erreur:", error);
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [UserManagement] Token invalide");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-cabine/cabine", { replace: true });
return;
}
alert("Erreur lors du chargement des clients");
} finally {
setLoading(false);
}
}, [navigate]);
// ============================================
// FILTERS
// ============================================
const filterUsers = useCallback(() => {
let filtered = [...users];
if (searchTerm) {
filtered = filtered.filter((user) =>
user.username.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
if (roleFilter !== "all") {
filtered = filtered.filter((user) => user.role === roleFilter);
}
setFilteredUsers(filtered);
}, [users, searchTerm, roleFilter]);
const filterClients = useCallback(() => {
let filtered = [...clients];
if (clientSearchTerm) {
const searchLower = clientSearchTerm.toLowerCase();
filtered = filtered.filter(
(client) =>
client.nom.toLowerCase().includes(searchLower) ||
client.prenom.toLowerCase().includes(searchLower) ||
client.username.toLowerCase().includes(searchLower) ||
client.telephone.includes(clientSearchTerm),
);
}
setFilteredClients(filtered);
}, [clients, clientSearchTerm]);
// ============================================
// HANDLERS
// ============================================
const handleViewClient = useCallback((client: ClientResponse) => {
setSelectedClient(client);
setShowClientModal(true);
}, []);
const handleCloseModal = useCallback(() => {
setShowClientModal(false);
setSelectedClient(null);
}, []);
const handleRoleFilterChange = useCallback((filter: RoleFilter) => {
setRoleFilter(filter);
}, []);
// ============================================
// EFFECTS
// ============================================
useEffect(() => {
fetchUsers();
fetchClients();
}, [fetchUsers, fetchClients]);
useEffect(() => {
filterUsers();
}, [filterUsers]);
useEffect(() => {
filterClients();
}, [filterClients]);
// ============================================
// RENDER HELPERS
// ============================================
const getRoleConfig = (role: string): RoleConfig => {
return ROLE_CONFIG[role as UserRole] || ROLE_CONFIG.client;
};
const getRoleIcon = (role: string): React.ReactElement => {
const config = getRoleConfig(role);
const IconComponent = config.icon;
return <IconComponent size={18} />;
};
// ============================================
// LOADING STATE
// ============================================
if (loading) {
return (
<div className="users-management-container">
<div className="loading-users">
<p>Chargement des utilisateurs...</p>
</div>
</div>
);
}
// ============================================
// MAIN RENDER
// ============================================
return (
<>
<SidebarCabine />
<div className="users-management-container">
<Header />
<StatsGrid stats={stats} totalClients={totalClients} />
<FiltersSection
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
roleFilter={roleFilter}
onRoleFilterChange={handleRoleFilterChange}
stats={stats}
usersCount={users.length}
/>
<UsersList
filteredUsers={filteredUsers}
getRoleIcon={getRoleIcon}
getRoleConfig={getRoleConfig}
/>
<ClientsSection
clients={clients}
filteredClients={filteredClients}
clientSearchTerm={clientSearchTerm}
setClientSearchTerm={setClientSearchTerm}
onViewClient={handleViewClient}
/>
{showClientModal && selectedClient && (
<ClientModal
client={selectedClient}
onClose={handleCloseModal}
/>
)}
</div>
</>
);
}
// ============================================
// SUB-COMPONENTS (identiques à l'original)
// ============================================
function Header() {
return (
<div className="dashboard-header">
<h1>Gestion des Utilisateurs</h1>
<p className="dashboard-subtitle">
Visualiser et gérer tous les comptes système
</p>
</div>
);
}
interface StatsGridProps {
stats: UserStats;
totalClients: number;
}
function StatsGrid({ stats, totalClients }: StatsGridProps) {
const statsCards = [
{
icon: User,
color: "blue",
label: "Total Utilisateurs",
value: stats.totalUsers,
},
{
icon: Store,
color: "purple",
label: "Cabines",
value: stats.totalCabines,
},
{
icon: Truck,
color: "blue",
label: "Livreurs",
value: stats.totalLivreurs,
},
{
icon: User,
color: "green",
label: "Clients",
value: totalClients,
},
];
return (
<div className="stats-grid">
{statsCards.map((card, index) => (
<StatCard key={index} {...card} />
))}
</div>
);
}
interface StatCardProps {
icon: React.ComponentType<{ size: number }>;
color: string;
label: string;
value: number;
}
function StatCard({ icon: Icon, color, label, value }: StatCardProps) {
return (
<div className="stat-card">
<div className={`stat-icon ${color}`}>
<Icon size={24} />
</div>
<div className="stat-content">
<p className="stat-label">{label}</p>
<h3 className="stat-value">{value}</h3>
</div>
</div>
);
}
interface FiltersSectionProps {
searchTerm: string;
setSearchTerm: (term: string) => void;
roleFilter: RoleFilter;
onRoleFilterChange: (filter: RoleFilter) => void;
stats: UserStats;
usersCount: number;
}
function FiltersSection({
searchTerm,
setSearchTerm,
roleFilter,
onRoleFilterChange,
stats,
usersCount,
}: FiltersSectionProps) {
const filters = [
{
value: "all" as RoleFilter,
icon: Filter,
label: "Tous",
count: usersCount,
},
{
value: "cabine" as RoleFilter,
icon: Store,
label: "Cabines",
count: stats.totalCabines,
},
{
value: "livreur" as RoleFilter,
icon: Truck,
label: "Livreurs",
count: stats.totalLivreurs,
},
];
return (
<div className="filters-section">
<div className="search-bar">
<Search size={20} />
<input
type="text"
placeholder="Rechercher par username..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="role-filters">
{filters.map((filter) => {
const IconComponent = filter.icon;
return (
<button
key={filter.value}
className={`filter-btn ${roleFilter === filter.value ? "active" : ""}`}
onClick={() => onRoleFilterChange(filter.value)}
>
<IconComponent size={18} />
{filter.label} ({filter.count})
</button>
);
})}
</div>
</div>
);
}
interface UsersListProps {
filteredUsers: AdminResponse[];
getRoleIcon: (role: string) => React.ReactElement;
getRoleConfig: (role: string) => RoleConfig;
}
function UsersList({
filteredUsers,
getRoleIcon,
getRoleConfig,
}: UsersListProps) {
return (
<div className="users-section">
<h2>
<User size={24} />
Utilisateurs Système ({filteredUsers.length})
</h2>
<div className="users-list">
{filteredUsers.length === 0 ? (
<EmptyState
icon={User}
title="Aucun utilisateur trouvé"
message="Essayez de modifier vos filtres de recherche"
/>
) : (
filteredUsers.map((user) => (
<UserCard
key={user.id}
user={user}
getRoleIcon={getRoleIcon}
getRoleConfig={getRoleConfig}
/>
))
)}
</div>
</div>
);
}
interface UserCardProps {
user: AdminResponse;
getRoleIcon: (role: string) => React.ReactElement;
getRoleConfig: (role: string) => RoleConfig;
}
function UserCard({ user, getRoleIcon, getRoleConfig }: UserCardProps) {
const roleConfig = getRoleConfig(user.role);
return (
<div className="user-card">
<div className="user-header">
<div className="user-info-main">
<div className="user-avatar">{getRoleIcon(user.role)}</div>
<div className="user-details">
<strong className="user-username">
{user.username}
</strong>
<span className="user-id">ID: {user.id}</span>
</div>
</div>
<div className={`user-role-badge ${roleConfig.color}`}>
{getRoleIcon(user.role)}
<span>{roleConfig.label}</span>
</div>
</div>
</div>
);
}
interface ClientsSectionProps {
clients: ClientResponse[];
filteredClients: ClientResponse[];
clientSearchTerm: string;
setClientSearchTerm: (term: string) => void;
onViewClient: (client: ClientResponse) => void;
}
function ClientsSection({
clients,
filteredClients,
clientSearchTerm,
setClientSearchTerm,
onViewClient,
}: ClientsSectionProps) {
return (
<div className="users-section clients-section">
<div className="section-header">
<h2>
<User size={24} />
Clients Enregistrés ({clients.length})
</h2>
<div className="search-bar compact">
<Search size={18} />
<input
type="text"
placeholder="Rechercher un client..."
value={clientSearchTerm}
onChange={(e) => setClientSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="users-list">
{filteredClients.length === 0 ? (
<EmptyState
icon={User}
title="Aucun client trouvé"
message={
clientSearchTerm
? "Essayez de modifier votre recherche"
: "Les clients s'afficheront ici une fois inscrits"
}
/>
) : (
filteredClients.map((client) => (
<ClientCard
key={client.id}
client={client}
onViewClient={onViewClient}
/>
))
)}
</div>
</div>
);
}
interface ClientCardProps {
client: ClientResponse;
onViewClient: (client: ClientResponse) => void;
}
function ClientCard({ client, onViewClient }: ClientCardProps) {
return (
<div className="user-card client-card">
<div className="user-header">
<div className="user-info-main">
<div className="user-avatar client">
<User size={18} />
</div>
<div className="user-details">
<strong className="user-username">
{client.prenom} {client.nom}
</strong>
<span className="user-id">@{client.username}</span>
</div>
</div>
<div className="user-role-badge green">
<User size={18} />
<span>Client</span>
</div>
</div>
<div className="client-info-grid">
<InfoItem icon={Phone} text={client.telephone} />
<InfoItem
icon={TrendingUp}
text={`${client.command} commande${client.command > 1 ? "s" : ""}`}
/>
<InfoItem icon={Leaf} text={`${client.point || 0} pts Weed`} />
<InfoItem
icon={Wind}
text={`${client.points_zipette || 0} pts Zipette`}
/>
<InfoItem
icon={AlertCircle}
text={`${client.amende}€ d'amendes`}
/>
</div>
<div className="user-actions">
<button
className="btn-view"
onClick={() => onViewClient(client)}
>
<Eye size={18} />
Voir détails
</button>
</div>
</div>
);
}
interface InfoItemProps {
icon: React.ComponentType<{ size: number }>;
text: string;
}
function InfoItem({ icon: Icon, text }: InfoItemProps) {
return (
<div className="info-item">
<Icon size={16} />
<span>{text}</span>
</div>
);
}
interface ClientModalProps {
client: ClientResponse;
onClose: () => void;
}
function ClientModal({ client, onClose }: ClientModalProps) {
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleBackdropClick}>
<div
className="edit-modal-content client-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="edit-modal-header">
<div className="modal-header-title">
<User size={24} />
<h3>Détails du Client</h3>
</div>
<button className="modal-close-btn" onClick={onClose}>
<XCircle size={20} />
</button>
</div>
<div className="edit-modal-body">
<DetailSection title="Informations personnelles">
<DetailItem
icon={User}
label="Prénom"
value={client.prenom}
/>
<DetailItem
icon={User}
label="Nom"
value={client.nom}
/>
<DetailItem
icon={Shield}
label="Username"
value={`@${client.username}`}
/>
<DetailItem
icon={Phone}
label="Téléphone"
value={client.telephone}
/>
</DetailSection>
<DetailSection title="Statistiques">
<DetailItem
icon={TrendingUp}
label="Commandes"
value={`${client.command} commande${client.command > 1 ? "s" : ""}`}
/>
<DetailItem
icon={Leaf}
label="Points Weed/Hash"
value={`${client.point || 0} points`}
/>
<DetailItem
icon={Wind}
label="Points Zipette"
value={`${client.points_zipette || 0} points`}
/>
<DetailItem
icon={AlertCircle}
label="Amendes"
value={`${client.amende}`}
/>
<DetailItem
icon={XCircle}
label="Annulations"
value={String(client.cancellations_count)}
/>
{client.last_penalty_reason && (
<DetailItem
icon={AlertCircle}
label="Dernière pénalité"
value={client.last_penalty_reason}
fullWidth
/>
)}
</DetailSection>
<DetailSection title="Informations système">
<DetailItem
icon={Shield}
label="ID Client"
value={`#${client.id}`}
/>
</DetailSection>
<div className="modal-actions">
<button className="btn-cancel" onClick={onClose}>
<XCircle size={18} />
Fermer
</button>
</div>
</div>
</div>
</div>
);
}
interface DetailSectionProps {
title: string;
children: React.ReactNode;
}
function DetailSection({ title, children }: DetailSectionProps) {
return (
<div className="client-detail-section">
<h4>{title}</h4>
<div className="detail-grid">{children}</div>
</div>
);
}
interface DetailItemProps {
icon: React.ComponentType<{ size: number }>;
label: string;
value: string;
fullWidth?: boolean;
}
function DetailItem({ icon: Icon, label, value, fullWidth }: DetailItemProps) {
return (
<div className={`detail-item ${fullWidth ? "full" : ""}`}>
<label>
<Icon size={16} />
{label}
</label>
<p>{value}</p>
</div>
);
}
interface EmptyStateProps {
icon: React.ComponentType<{ size: number }>;
title: string;
message: string;
}
function EmptyState({ icon: Icon, title, message }: EmptyStateProps) {
return (
<div className="empty-state">
<div style={{ opacity: 0.3 }}>
<Icon size={64} />
</div>
<h3>{title}</h3>
<p>{message}</p>
</div>
);
}
export default UsersManagement;
@@ -0,0 +1,218 @@
.cp-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #09090b;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.cp-content {
width: 100%;
max-width: 28rem;
}
.cp-header {
text-align: center;
margin-bottom: 2rem;
}
.cp-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 1rem;
margin-bottom: 1rem;
box-shadow: 0 10px 24px rgba(109, 40, 217, 0.45);
}
.cp-title {
font-size: 1.6rem;
font-weight: 700;
color: #f4f4f5;
margin-bottom: 0.5rem;
}
.cp-subtitle {
color: #71717a;
font-size: 0.9rem;
line-height: 1.5;
padding: 0 0.5rem;
}
.cp-card {
background-color: #111115;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
padding: 2rem;
}
.cp-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.cp-form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.cp-label {
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
}
.cp-input-wrapper {
position: relative;
}
.cp-input-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
pointer-events: none;
color: #52525b;
}
.cp-input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 3rem;
background-color: #09090b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
color: #f4f4f5;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.cp-input::placeholder {
color: #52525b;
}
.cp-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
}
.cp-input-error {
border-color: #ef4444;
}
.cp-toggle {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
background: transparent;
border: none;
color: #52525b;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.cp-toggle:hover {
color: #a1a1aa;
}
.cp-hint {
font-size: 0.78rem;
color: #71717a;
margin: 0;
}
.cp-error-msg {
font-size: 0.78rem;
color: #f87171;
margin: 0;
}
.cp-error-banner {
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 0.5rem;
color: #f87171;
font-size: 0.875rem;
}
.cp-submit {
width: 100%;
padding: 0.8rem 1rem;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
color: #fff;
font-weight: 600;
font-size: 0.95rem;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
box-shadow: 0 4px 16px rgba(109, 40, 217, 0.4);
margin-top: 0.25rem;
}
.cp-submit:hover:not(:disabled) {
opacity: 0.9;
}
.cp-submit:active:not(:disabled) {
transform: scale(0.98);
}
.cp-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Success state */
.cp-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0;
text-align: center;
color: #f4f4f5;
}
.cp-success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
background: rgba(34, 197, 94, 0.15);
border: 2px solid rgba(34, 197, 94, 0.4);
border-radius: 50%;
font-size: 1.5rem;
color: #4ade80;
}
.cp-success p {
margin: 0;
font-weight: 600;
font-size: 1rem;
}
.cp-success-sub {
color: #71717a;
font-size: 0.85rem;
font-weight: 400 !important;
}
@@ -0,0 +1,190 @@
import { useState } from "react";
import { Lock, Eye, EyeOff, ShieldCheck } from "lucide-react";
import "./ChangePassword.css";
import { changePassword } from "../../api/api";
import { useNavigate } from "react-router-dom";
const ChangePasswordPage = () => {
const navigate = useNavigate();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const validate = (): string | null => {
if (!currentPassword) return "Le mot de passe actuel est requis";
if (!newPassword) return "Le nouveau mot de passe est requis";
if (newPassword.length < 8) return "Le nouveau mot de passe doit contenir au moins 8 caractères";
if (newPassword === currentPassword) return "Le nouveau mot de passe doit être différent de l'ancien";
if (newPassword !== confirmPassword) return "Les mots de passe ne correspondent pas";
return null;
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setIsLoading(true);
try {
const result = await changePassword(currentPassword, newPassword);
if (result.success) {
setSuccess(true);
setTimeout(() => navigate("/user/accueil"), 1800);
} else {
setError(result.message);
}
} finally {
setIsLoading(false);
}
};
return (
<div className="cp-container">
<div className="cp-content">
<div className="cp-header">
<div className="cp-logo">
<ShieldCheck className="w-8 h-8 text-white" />
</div>
<h1 className="cp-title">Changement de mot de passe</h1>
<p className="cp-subtitle">
Pour votre sécurité, veuillez définir un nouveau mot de passe
avant de continuer.
</p>
</div>
<div className="cp-card">
{success ? (
<div className="cp-success">
<span className="cp-success-icon"></span>
<p>Mot de passe mis à jour avec succès !</p>
<p className="cp-success-sub">Redirection en cours</p>
</div>
) : (
<form className="cp-form" onSubmit={handleSubmit}>
{error && (
<div className="cp-error-banner">
{error}
</div>
)}
{/* Mot de passe actuel */}
<div className="cp-form-group">
<label className="cp-label">Mot de passe actuel</label>
<div className="cp-input-wrapper">
<Lock className="cp-input-icon" style={{ width: "18px", height: "18px" }} />
<input
type={showCurrent ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setError(""); }}
className="cp-input"
placeholder="••••••••"
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
className="cp-toggle"
onClick={() => setShowCurrent((v) => !v)}
disabled={isLoading}
>
{showCurrent ? (
<EyeOff style={{ width: "18px", height: "18px" }} />
) : (
<Eye style={{ width: "18px", height: "18px" }} />
)}
</button>
</div>
</div>
{/* Nouveau mot de passe */}
<div className="cp-form-group">
<label className="cp-label">Nouveau mot de passe</label>
<div className="cp-input-wrapper">
<Lock className="cp-input-icon" style={{ width: "18px", height: "18px" }} />
<input
type={showNew ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(""); }}
className="cp-input"
placeholder="Minimum 8 caractères"
disabled={isLoading}
autoComplete="new-password"
/>
<button
type="button"
className="cp-toggle"
onClick={() => setShowNew((v) => !v)}
disabled={isLoading}
>
{showNew ? (
<EyeOff style={{ width: "18px", height: "18px" }} />
) : (
<Eye style={{ width: "18px", height: "18px" }} />
)}
</button>
</div>
{newPassword.length > 0 && newPassword.length < 8 && (
<p className="cp-hint">Encore {8 - newPassword.length} caractère{8 - newPassword.length > 1 ? "s" : ""} minimum</p>
)}
</div>
{/* Confirmer */}
<div className="cp-form-group">
<label className="cp-label">Confirmer le nouveau mot de passe</label>
<div className="cp-input-wrapper">
<Lock className="cp-input-icon" style={{ width: "18px", height: "18px" }} />
<input
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(""); }}
className={`cp-input ${confirmPassword && confirmPassword !== newPassword ? "cp-input-error" : ""}`}
placeholder="••••••••"
disabled={isLoading}
autoComplete="new-password"
/>
<button
type="button"
className="cp-toggle"
onClick={() => setShowConfirm((v) => !v)}
disabled={isLoading}
>
{showConfirm ? (
<EyeOff style={{ width: "18px", height: "18px" }} />
) : (
<Eye style={{ width: "18px", height: "18px" }} />
)}
</button>
</div>
{confirmPassword && confirmPassword !== newPassword && (
<p className="cp-error-msg">Les mots de passe ne correspondent pas</p>
)}
</div>
<button
type="submit"
className="cp-submit"
disabled={isLoading}
>
{isLoading ? "Mise à jour…" : "Confirmer le nouveau mot de passe"}
</button>
</form>
)}
</div>
</div>
</div>
);
};
export default ChangePasswordPage;
@@ -1,651 +0,0 @@
/* ============================================
ALERT HISTORY - CONTAINER
============================================ */
.alert-history-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1400px;
margin: 0 auto;
}
/* ============================================
HEADER
============================================ */
.alert-history-header {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #9ca3af;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 2rem;
}
.back-button:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.1) 0%,
rgba(5, 150, 105, 0.05) 100%
);
border-color: rgba(16, 185, 129, 0.3);
color: #10b981;
transform: translateX(-4px);
}
.header-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
.header-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.alert-history-header h1 {
color: white;
font-size: clamp(2rem, 6vw, 2.5rem);
margin: 0 0 0.5rem 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.header-subtitle {
color: #888;
font-size: clamp(1rem, 3vw, 1.1rem);
margin: 0;
}
/* ============================================
STATS CARDS
============================================ */
.alert-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.alert-stat-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
align-items: center;
gap: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.alert-stat-card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(16, 185, 129, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.alert-stat-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.2);
transform: translateY(-4px);
}
.alert-stat-card:hover::before {
left: 100%;
}
.alert-stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.alert-stat-card:hover .alert-stat-icon {
transform: scale(1.1) rotate(5deg);
}
.alert-stat-icon.total {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
}
.alert-stat-icon.active {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
color: #ef4444;
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
}
.alert-stat-icon.resolved {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
color: #10b981;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
.alert-stat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-stat-label {
color: #888;
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.alert-stat-value {
color: white;
font-size: clamp(1.8rem, 5vw, 2.2rem);
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
/* ============================================
FILTERS
============================================ */
.alert-filters {
display: flex;
gap: 1rem;
margin-bottom: clamp(2rem, 5vw, 3rem);
flex-wrap: wrap;
}
.filter-button {
padding: 0.75rem 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
color: #9ca3af;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
}
.filter-button:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.1) 0%,
rgba(5, 150, 105, 0.05) 100%
);
border-color: rgba(16, 185, 129, 0.3);
color: #10b981;
transform: translateY(-2px);
}
.filter-button.active {
background: linear-gradient(135deg, #10b981, #059669);
border-color: #10b981;
color: white;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
}
.filter-button.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
}
/* ============================================
ALERTS LIST
============================================ */
.alerts-section {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.alert-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.alert-card.active {
border-color: rgba(239, 68, 68, 0.3);
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.05) 0%,
rgba(220, 38, 38, 0.02) 100%
);
}
.alert-card.resolved {
border-color: rgba(16, 185, 129, 0.2);
}
.alert-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
.alert-card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.alert-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.alert-card.active .alert-card-icon {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
color: #ef4444;
}
.alert-card.resolved .alert-card-icon {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
color: #10b981;
}
.alert-card-info {
flex: 1;
}
.alert-card-info h3 {
color: white;
font-size: 1.2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
}
.alert-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
color: #9ca3af;
font-size: 0.9rem;
}
.alert-status-badge {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.alert-status-badge.active {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.4);
animation: pulse-badge 2s infinite;
}
.alert-status-badge.resolved {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.4);
}
@keyframes pulse-badge {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
.alert-card-details {
display: flex;
flex-direction: column;
gap: 1rem;
}
.alert-detail-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
.alert-detail-item svg {
color: #10b981;
flex-shrink: 0;
margin-top: 2px;
}
.alert-detail-item > div {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-label {
color: #888;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.detail-value {
color: white;
font-size: 1rem;
font-weight: 500;
}
.alert-active-indicator {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%
);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #ef4444;
font-weight: 600;
}
.pulse-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ef4444;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
/* ============================================
NO ALERTS CARD
============================================ */
.no-alerts-card {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.no-alerts-card p {
color: #9ca3af;
font-size: 1.2rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.no-alerts-text {
color: #6b7280;
font-size: 0.95rem;
}
/* ============================================
INFO BOX
============================================ */
.info-box {
display: flex;
gap: 1rem;
padding: 1.5rem;
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.1) 0%,
rgba(37, 99, 235, 0.05) 100%
);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 16px;
color: #3b82f6;
}
.info-box svg {
flex-shrink: 0;
margin-top: 2px;
}
.info-box h4 {
color: white;
font-size: 1rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
}
.info-box p {
color: #9ca3af;
font-size: 0.95rem;
line-height: 1.6;
margin: 0;
}
/* ============================================
LOADING
============================================ */
.loading-alerts {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 4rem 2rem;
text-align: center;
}
.loading-alerts p {
color: #9ca3af;
font-size: 1.2rem;
font-weight: 600;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: center;
}
.header-icon {
width: 56px;
height: 56px;
}
.alert-stats-grid {
grid-template-columns: 1fr;
}
.alert-filters {
flex-direction: column;
}
.filter-button {
width: 100%;
}
.alert-card-header {
flex-wrap: wrap;
}
.alert-status-badge {
width: 100%;
text-align: center;
}
}
@media (max-width: 480px) {
.alert-card-header {
flex-direction: column;
align-items: flex-start;
}
.alert-card-icon {
width: 40px;
height: 40px;
}
.alert-card-info h3 {
font-size: 1.1rem;
}
}
@media (hover: none) {
.back-button:hover,
.alert-stat-card:hover,
.filter-button:hover,
.alert-card:hover {
transform: none;
}
}
@@ -1,385 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Shield,
ArrowLeft,
Clock,
CheckCircle,
AlertTriangle,
Calendar,
Info,
} from "lucide-react";
import "./AlertHistory.css";
import { getMyAlerts, isDeliveryAuthenticated } from "../../api/api_delivery";
import { extractAdminUsernameFromToken } from "../../api/api_admin";
interface Alert {
id: number;
username: string;
status: string;
created_at: string;
updated_at: string;
}
interface AlertStats {
total: number;
active: number;
resolved: number;
}
function AlertHistory() {
const navigate = useNavigate();
const [alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<AlertStats>({
total: 0,
active: 0,
resolved: 0,
});
const [filter, setFilter] = useState<"all" | "active" | "resolved">("all");
const [deliveryPersonName, setDeliveryPersonName] = useState("");
useEffect(() => {
const checkAuth = () => {
if (!isDeliveryAuthenticated()) {
console.log(
"❌ [ALERT_HISTORY] Non authentifié, redirection vers /login-delivery/delivery",
);
navigate("/login-delivery/delivery", { replace: true });
}
};
checkAuth();
}, [navigate]);
useEffect(() => {
const fetchAlerts = async () => {
try {
const username = extractAdminUsernameFromToken();
if (!username) {
console.error("❌ [ALERT_HISTORY] Username non trouvé");
setLoading(false);
return;
}
setDeliveryPersonName(username);
console.log(
"🔍 [ALERT_HISTORY] Chargement alertes pour:",
username,
);
const result = await getMyAlerts();
if (result.success && result.alerts) {
const alertsData = result.alerts;
console.log("✅ [ALERT_HISTORY] Alertes:", alertsData);
setAlerts(alertsData);
// Calculer les statistiques
const total = alertsData.length;
const active = alertsData.filter(
(alert) => alert.status === "true",
).length;
const resolved = alertsData.filter(
(alert) => alert.status === "false",
).length;
setStats({ total, active, resolved });
} else {
console.error("❌ [ALERT_HISTORY] Erreur:", result.error);
}
} catch (error) {
console.error(
"❌ [ALERT_HISTORY] Erreur chargement alertes:",
error,
);
} finally {
setLoading(false);
}
};
fetchAlerts();
}, []);
const getFilteredAlerts = () => {
switch (filter) {
case "active":
return alerts.filter((alert) => alert.status === "true");
case "resolved":
return alerts.filter((alert) => alert.status === "false");
default:
return alerts;
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `Aujourd'hui à ${date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}`;
} else if (diffDays === 1) {
return `Hier à ${date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}`;
} else if (diffDays < 7) {
return `Il y a ${diffDays} jours`;
} else {
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
};
const getDuration = (createdAt: string, updatedAt: string) => {
const start = new Date(createdAt);
const end = new Date(updatedAt);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "< 1 min";
if (diffMins < 60) return `${diffMins} min`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
return mins > 0 ? `${hours}h ${mins}min` : `${hours}h`;
};
if (loading) {
return (
<div className="alert-history-container">
<div className="loading-alerts">
<Shield size={48} color="#10b981" />
<p>Chargement de l'historique...</p>
</div>
</div>
);
}
const filteredAlerts = getFilteredAlerts();
return (
<div className="alert-history-container">
{/* Header */}
<div className="alert-history-header">
<button
className="back-button"
onClick={() => navigate("/delivery/dashboard")}
>
<ArrowLeft size={20} />
<span>Retour</span>
</button>
<div className="header-content">
<div className="header-icon">
<Shield size={32} color="#10b981" />
</div>
<div>
<h1>Historique des Alertes</h1>
<p className="header-subtitle">
Toutes vos alertes police - {deliveryPersonName}
</p>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="alert-stats-grid">
<div className="alert-stat-card">
<div className="alert-stat-icon total">
<Shield size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Total Alertes</p>
<h3 className="alert-stat-value">{stats.total}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon active">
<AlertTriangle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Alertes Actives</p>
<h3 className="alert-stat-value">{stats.active}</h3>
</div>
</div>
<div className="alert-stat-card">
<div className="alert-stat-icon resolved">
<CheckCircle size={24} />
</div>
<div className="alert-stat-content">
<p className="alert-stat-label">Résolues</p>
<h3 className="alert-stat-value">{stats.resolved}</h3>
</div>
</div>
</div>
{/* Filters */}
<div className="alert-filters">
<button
className={`filter-button ${filter === "all" ? "active" : ""}`}
onClick={() => setFilter("all")}
>
Toutes ({stats.total})
</button>
<button
className={`filter-button ${filter === "active" ? "active" : ""}`}
onClick={() => setFilter("active")}
>
Actives ({stats.active})
</button>
<button
className={`filter-button ${filter === "resolved" ? "active" : ""}`}
onClick={() => setFilter("resolved")}
>
Résolues ({stats.resolved})
</button>
</div>
{/* Alerts List */}
<div className="alerts-section">
{filteredAlerts.length === 0 ? (
<div className="no-alerts-card">
<Info size={48} color="#888" />
<p>Aucune alerte trouvée</p>
<span className="no-alerts-text">
{filter === "active"
? "Vous n'avez aucune alerte active en ce moment."
: filter === "resolved"
? "Vous n'avez aucune alerte résolue."
: "Vous n'avez pas encore déclenché d'alerte."}
</span>
</div>
) : (
<div className="alerts-list">
{filteredAlerts.map((alert) => (
<div
key={alert.id}
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
>
<div className="alert-card-header">
<div className="alert-card-icon">
{alert.status === "true" ? (
<AlertTriangle size={24} />
) : (
<CheckCircle size={24} />
)}
</div>
<div className="alert-card-info">
<h3>Alerte #{alert.id}</h3>
<div className="alert-card-meta">
<Calendar size={14} />
<span>
{formatDate(alert.created_at)}
</span>
</div>
</div>
<div
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
>
{alert.status === "true"
? "Active"
: "Résolue"}
</div>
</div>
<div className="alert-card-details">
<div className="alert-detail-item">
<Clock size={16} />
<div>
<span className="detail-label">
Déclenchée
</span>
<span className="detail-value">
{new Date(
alert.created_at,
).toLocaleString("fr-FR")}
</span>
</div>
</div>
{alert.status === "false" && (
<>
<div className="alert-detail-item">
<CheckCircle size={16} />
<div>
<span className="detail-label">
Terminée
</span>
<span className="detail-value">
{new Date(
alert.updated_at,
).toLocaleString(
"fr-FR",
)}
</span>
</div>
</div>
<div className="alert-detail-item">
<Clock size={16} />
<div>
<span className="detail-label">
Durée
</span>
<span className="detail-value">
{getDuration(
alert.created_at,
alert.updated_at,
)}
</span>
</div>
</div>
</>
)}
{alert.status === "true" && (
<div className="alert-active-indicator">
<div className="pulse-indicator"></div>
<span>Alerte en cours...</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Info Box */}
{alerts.length > 0 && (
<div className="info-box">
<Info size={20} />
<div>
<h4>À propos des alertes police</h4>
<p>
Les alertes police sont déclenchées lorsque vous
vous sentez en danger. Elles notifient immédiatement
votre équipe et les autorités. Une fois la situation
résolue, n'oubliez pas de terminer l'alerte depuis
votre tableau de bord.
</p>
</div>
</div>
)}
</div>
);
}
export default AlertHistory;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,547 +0,0 @@
.stats-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
max-width: 1400px;
margin: 0 auto;
}
/* Back Button */
.back-button-container {
margin-bottom: clamp(1rem, 3vw, 1.5rem);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
}
.back-button:hover {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.3),
rgba(37, 99, 235, 0.2)
);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
transform: translateY(-2px);
}
.back-button:active {
transform: translateY(0);
}
/* Header */
.stats-header {
margin-bottom: clamp(2rem, 5vw, 3rem);
text-align: center;
}
.stats-header h1 {
color: white;
font-size: clamp(2rem, 6vw, 3rem);
margin: 0 0 0.5rem 0;
font-weight: bold;
letter-spacing: -0.5px;
background: linear-gradient(to right, #10b981, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stats-subtitle {
color: #888;
font-size: clamp(1rem, 3vw, 1.2rem);
margin: 0 0 1rem 0;
}
.stats-welcome {
color: #10b981;
font-size: clamp(1rem, 3vw, 1.1rem);
font-weight: 600;
margin: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.stat-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
align-items: center;
gap: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.stat-card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(16, 185, 129, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.stat-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.2);
transform: translateY(-4px);
}
.stat-card:hover::before {
left: 100%;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-card:hover .stat-icon {
transform: scale(1.1) rotate(5deg);
}
.stat-icon.green {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
color: #10b981;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
.stat-icon.blue {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
color: #3b82f6;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
}
.stat-icon.orange {
background: linear-gradient(
135deg,
rgba(251, 146, 60, 0.2),
rgba(249, 115, 22, 0.1)
);
color: #fb923c;
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
}
.stat-icon.purple {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.2),
rgba(109, 40, 217, 0.1)
);
color: #7c3aed;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
}
.stat-icon.yellow {
background: linear-gradient(
135deg,
rgba(234, 179, 8, 0.2),
rgba(202, 138, 4, 0.1)
);
color: #eab308;
box-shadow: 0 8px 24px rgba(234, 179, 8, 0.3);
}
.stat-icon.teal {
background: linear-gradient(
135deg,
rgba(20, 184, 166, 0.2),
rgba(13, 148, 136, 0.1)
);
color: #14b8a6;
box-shadow: 0 8px 24px rgba(20, 184, 166, 0.3);
}
.stat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-label {
color: #888;
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.stat-value {
color: white;
font-size: clamp(1.8rem, 5vw, 2.2rem);
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 600;
}
.stat-trend.positive {
color: #10b981;
}
.stat-trend.neutral {
color: #fb923c;
}
/* Performance Section */
.performance-section {
margin-bottom: clamp(2rem, 5vw, 3rem);
}
.performance-section h2 {
color: white;
font-size: clamp(1.5rem, 4vw, 2rem);
margin: 0 0 clamp(1.5rem, 4vw, 2rem) 0;
font-weight: bold;
}
.performance-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: clamp(1rem, 3vw, 1.5rem);
}
.performance-card {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.performance-card:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.performance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.performance-header h3 {
color: white;
font-size: clamp(1.1rem, 3vw, 1.3rem);
margin: 0;
font-weight: 600;
}
.performance-percentage {
color: #10b981;
font-size: clamp(1.5rem, 4vw, 1.8rem);
font-weight: bold;
}
.performance-bar {
width: 100%;
height: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
margin-bottom: 1rem;
}
.performance-fill {
height: 100%;
background: linear-gradient(135deg, #10b981, #059669);
border-radius: 6px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.performance-fill.approval {
background: linear-gradient(135deg, #14b8a6, #0f766e);
}
.performance-fill::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.performance-description {
color: #9ca3af;
font-size: clamp(0.9rem, 2.5vw, 1rem);
margin: 0;
line-height: 1.5;
}
/* Quick Summary */
.quick-summary {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.quick-summary h3 {
color: white;
font-size: clamp(1.3rem, 3.5vw, 1.5rem);
margin: 0 0 1.5rem 0;
font-weight: 600;
}
.summary-items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
transition: all 0.3s ease;
}
.summary-item:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.05) 0%,
rgba(5, 150, 105, 0.02) 100%
);
border-color: rgba(16, 185, 129, 0.2);
transform: translateX(4px);
}
.summary-label {
color: #9ca3af;
font-size: clamp(0.9rem, 2.5vw, 1rem);
font-weight: 500;
}
.summary-value {
color: white;
font-size: clamp(0.9rem, 2.5vw, 1rem);
font-weight: 600;
}
.summary-value.status-active {
color: #10b981;
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
}
/* Loading */
.loading-stats {
text-align: center;
padding: 4rem 2rem;
}
.loading-stats p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Responsive */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.performance-cards {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.stat-icon {
width: 50px;
height: 50px;
}
.performance-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.summary-items {
grid-template-columns: 1fr;
}
.summary-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.back-button {
font-size: 0.9rem;
padding: 0.7rem 1.2rem;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card {
flex-direction: column;
text-align: center;
}
.stat-icon {
width: 60px;
height: 60px;
}
.back-button {
width: 100%;
justify-content: center;
font-size: 0.85rem;
padding: 0.6rem 1rem;
}
}
@media (hover: none) {
.stat-card:hover {
transform: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.performance-card:hover {
transform: none;
}
.summary-item:hover {
transform: none;
}
}
@@ -1,245 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Package,
CheckCircle,
Activity,
TrendingUp,
ArrowLeft,
} from "lucide-react";
import "./StatsPage.css";
import { extractAdminUsernameFromToken } from "../../api/api_admin";
import {
getMyDeliveries,
isDeliveryAuthenticated,
} from "../../api/api_delivery";
interface DeliveryStats {
totalDeliveries: number;
completedDeliveries: number;
todayDeliveries: number;
thisWeekDeliveries: number;
thisMonthDeliveries: number;
approvedDeliveries: number;
}
function StatsPage() {
const navigate = useNavigate();
const [stats, setStats] = useState<DeliveryStats>({
totalDeliveries: 0,
completedDeliveries: 0,
todayDeliveries: 0,
thisWeekDeliveries: 0,
thisMonthDeliveries: 0,
approvedDeliveries: 0,
});
const [loading, setLoading] = useState(true);
const [deliveryPersonName, setDeliveryPersonName] = useState("");
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isDeliveryAuthenticated()) {
console.log(
"❌ [StatsPage] Livreur non authentifié, redirection vers /login-delivery/delivery",
);
navigate("/login-delivery/delivery", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isDeliveryAuthenticated()) {
console.log(
"❌ [StatsPage] Session livreur expirée, redirection vers /login-delivery/delivery",
);
navigate("/login-delivery/delivery", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
const fetchStats = async () => {
// ✅ Vérifier l'auth avant de charger les données
if (!isDeliveryAuthenticated()) {
console.log("❌ [fetchStats] Livreur non authentifié");
navigate("/login-delivery/delivery", { replace: true });
return;
}
try {
const username = extractAdminUsernameFromToken();
setDeliveryPersonName(username || "Livreur");
console.log("📊 [STATS_PAGE] Chargement des statistiques...");
const deliveriesResult = await getMyDeliveries();
if (deliveriesResult.success && deliveriesResult.deliveries) {
const deliveries = deliveriesResult.deliveries;
console.log(
"✅ [STATS_PAGE] Livraisons récupérées:",
deliveries,
);
const now = new Date();
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const thisWeekStart = new Date(today);
thisWeekStart.setDate(today.getDate() - today.getDay());
const thisMonthStart = new Date(
now.getFullYear(),
now.getMonth(),
1,
);
// Calcul des statistiques
const totalDeliveries = deliveries.length;
const completedDeliveries = deliveries.filter(
(d: any) => d.status === "livre",
).length;
const todayDeliveries = deliveries.filter((d: any) => {
const deliveryDate = new Date(d.updated_at);
return d.status === "livre" && deliveryDate >= today;
}).length;
const thisWeekDeliveries = deliveries.filter((d: any) => {
const deliveryDate = new Date(d.updated_at);
return (
d.status === "livre" &&
deliveryDate >= thisWeekStart
);
}).length;
const thisMonthDeliveries = deliveries.filter((d: any) => {
const deliveryDate = new Date(d.updated_at);
return (
d.status === "livre" &&
deliveryDate >= thisMonthStart
);
}).length;
const approvedDeliveries = deliveries.filter(
(d: any) => d.status === "approved",
).length;
setStats({
totalDeliveries,
completedDeliveries,
todayDeliveries,
thisWeekDeliveries,
thisMonthDeliveries,
approvedDeliveries,
});
console.log("✅ [STATS_PAGE] Statistiques calculées:", {
totalDeliveries,
completedDeliveries,
todayDeliveries,
thisWeekDeliveries,
thisMonthDeliveries,
approvedDeliveries,
});
}
} catch (error) {
console.error(
"❌ [STATS_PAGE] Erreur chargement stats:",
error,
);
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [StatsPage] Token invalide - Redirection");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-delivery/delivery", { replace: true });
}
} finally {
setLoading(false);
}
};
fetchStats();
}, [navigate]);
if (loading) {
return (
<div className="stats-container">
<div className="loading-stats">
<p>Chargement des statistiques...</p>
</div>
</div>
);
}
return (
<div className="stats-container">
{/* Back Button */}
<div className="back-button-container">
<button
className="back-button"
onClick={() => navigate("/delivery/dashboard")}
>
<ArrowLeft size={20} />
Retour au Dashboard
</button>
</div>
{/* Header */}
<div className="stats-header">
<h1>Mes Statistiques</h1>
<p className="stats-subtitle">
Tableau de bord de vos performances de livraison
</p>
<p className="stats-welcome">Bonjour, {deliveryPersonName}!</p>
</div>
{/* Stats Cards */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon green">
<Package size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Total Livraisons</p>
<h3 className="stat-value">{stats.totalDeliveries}</h3>
<span className="stat-trend neutral">
<Activity size={14} /> Toutes les livraisons
</span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon teal">
<CheckCircle size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Approuvées</p>
<h3 className="stat-value">
{stats.approvedDeliveries}
</h3>
<span className="stat-trend positive">
<TrendingUp size={14} /> Validées par clients
</span>
</div>
</div>
</div>
{/* Quick Stats Summary */}
</div>
);
}
export default StatsPage;
@@ -1,262 +0,0 @@
.login-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.login-content {
width: 100%;
max-width: 28rem;
position: relative;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
border-radius: 1rem;
margin-bottom: 1rem;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.login-title {
font-size: 1.875rem;
font-weight: bold;
color: white;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: #9ca3af;
}
.login-card {
background-color: #000000;
backdrop-filter: blur(16px);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(75, 85, 99, 0.5);
padding: 2rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
pointer-events: none;
color: #6b7280;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
background-color: #000000;
border: 1px solid #4b5563;
border-radius: 0.5rem;
color: white;
font-size: 1rem;
transition: all 0.3s;
}
.form-input::placeholder {
color: #6b7280;
}
.form-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
0 0 20px rgba(124, 58, 237, 0.3),
0 0 30px rgba(124, 58, 237, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.password-toggle:hover {
color: #d1d5db;
}
.error-message {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #f87171;
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
}
.remember-me {
display: flex;
align-items: center;
cursor: pointer;
}
.remember-me input {
width: 1rem;
height: 1rem;
background-color: #111827;
border: 1px solid #4b5563;
border-radius: 0.25rem;
cursor: pointer;
accent-color: #7c3aed;
}
.remember-me span {
margin-left: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.forgot-password {
background: transparent;
border: none;
font-size: 0.875rem;
color: #a78bfa;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.forgot-password:hover {
color: #c4b5fd;
}
.submit-button {
width: 100%;
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.submit-button:hover:not(:disabled) {
background: linear-gradient(to right, #6d28d9, #5b21b6);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 1s linear infinite;
margin-right: 0.75rem;
height: 1.25rem;
width: 1.25rem;
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.signup-section {
margin-top: 1.5rem;
text-align: center;
}
.signup-text {
color: #9ca3af;
font-size: 0.875rem;
}
.signup-link {
background: transparent;
border: none;
color: #a78bfa;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.signup-link:hover {
color: #c4b5fd;
}
.login-footer {
margin-top: 2rem;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
@@ -1,261 +0,0 @@
import { useState } from 'react';
import { Lock, User, Eye, EyeOff, Shield } from 'lucide-react';
import './Login.css';
import { loginAdmin, syncAdminUsernameFromJWT } from '../../api/api_admin';
import { useNavigate } from 'react-router-dom';
interface AdminLoginRequest {
username: string;
password: string;
}
const LoginAdmin = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<AdminLoginRequest>({
username: '',
password: ''
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>('');
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: { username?: string; password?: string } = {};
console.log('🔍 [ADMIN_LOGIN] Validation - Username:', `"${formData.username}"`);
console.log('🔍 [ADMIN_LOGIN] Après trim:', `"${formData.username.trim()}"`);
if (!formData.username.trim()) {
newErrors.username = 'L\'username admin est requis';
} else if (formData.username.trim().length < 3) {
newErrors.username = 'Username invalide (minimum 3 caractères)';
}
if (!formData.password) {
newErrors.password = 'Le mot de passe est requis';
} else if (formData.password.length < 6) {
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setApiError('');
try {
console.log('🔐 [ADMIN_LOGIN] Appel API avec:', {
username: formData.username,
password: '***'
});
// ✅ Appeler la fonction de login admin sécurisée
const result = await loginAdmin(formData.username, formData.password);
console.log('📋 [ADMIN_LOGIN] Résultat:', {
success: result.success,
message: result.message,
hasToken: !!result.access_token,
role: result.user?.role
});
if (result.success && result.access_token) {
console.log('✅ [ADMIN_LOGIN] Connexion admin réussie!');
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
// Pas besoin de le faire ici
console.log('✅ [ADMIN_LOGIN] Token et username admin synchronisés');
// ✅ Vérifier la synchronisation
const syncedUsername = syncAdminUsernameFromJWT();
console.log('✅ [ADMIN_LOGIN] Username admin synchronisé:', syncedUsername);
// ✅ Redirection vers dashboard admin
console.log('✅ [ADMIN_LOGIN] Redirection vers /admin/dashboard');
navigate('/admin/dashboard');
} else {
// ❌ Erreur API
const errorMessage = result.message || 'Identifiants admin incorrects';
console.error('❌ [ADMIN_LOGIN] Erreur API:', errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error('💥 [ADMIN_LOGIN] Erreur catch:', err);
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion admin';
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError('');
}
};
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Connexion du staff</h1>
<p className="login-subtitle">Accédez au panneau d'administration</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handleSubmit}>
{/* ✅ Erreur API globale */}
{apiError && (
<div className="error-banner" style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fee',
borderLeft: '4px solid #f44',
borderRadius: '4px',
color: '#c33'
}}>
{apiError}
</div>
)}
{/* Username Admin */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Username Administrateur
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Username admin"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && (
<p className="error-message">{errors.username}</p>
)}
</div>
{/* Password */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="••••••••"
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff style={{ width: '20px', height: '20px' }} />
) : (
<Eye style={{ width: '20px', height: '20px' }} />
)}
</button>
</div>
{errors.password && (
<p className="error-message">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? (
<span className="loading-spinner">
Connexion en cours...
</span>
) : (
'Se connecter'
)}
</button>
</form>
{/* Info sécurité */}
<div className="signup-section">
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
Accès réservé aux administrateurs
</p>
</div>
</div>
{/* Footer */}
<div className="login-footer">
<p>
Besoin d'aide ? Contactez le support technique
</p>
</div>
</div>
</div>
);
};
export default LoginAdmin;
+258 -226
View File
@@ -1,256 +1,288 @@
import { useState } from 'react';
import { Lock, Mail, Eye, EyeOff, User } from 'lucide-react';
import './Login.css';
import { loginUser, syncUsernameFromJWT } from '../../api/api';
import type { LoginRequest } from '../../api/api_types';
import { useNavigate } from 'react-router-dom';
import { useState } from "react";
import { Lock, Mail, Eye, EyeOff, User } from "lucide-react";
import "./Login.css";
import { loginUser, syncUsernameFromJWT } from "../../api/api";
import type { LoginRequest } from "../../api/api_types";
import { useNavigate } from "react-router-dom";
const LoginClient = () => {
const navigate = useNavigate();
const navigate = useNavigate();
const [formData, setFormData] = useState<LoginRequest>({
username: '',
password: ''
});
const [formData, setFormData] = useState<LoginRequest>({
username: "",
password: "",
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>('');
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{
username?: string;
password?: string;
}>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>("");
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: { username?: string; password?: string } = {};
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: { username?: string; password?: string } = {};
console.log('🔍 [LOGIN] Validation - Username:', `"${formData.username}"`);
console.log('🔍 [LOGIN] Après trim:', `"${formData.username.trim()}"`);
console.log(
"🔍 [LOGIN] Validation - Username:",
`"${formData.username}"`,
);
console.log("🔍 [LOGIN] Après trim:", `"${formData.username.trim()}"`);
if (!formData.username.trim()) {
newErrors.username = 'L\'username est requis';
} else if (formData.username.trim().length < 3) {
newErrors.username = 'Username invalide (minimum 3 caractères)';
}
if (!formData.username.trim()) {
newErrors.username = "L'username est requis";
} else if (formData.username.trim().length < 3) {
newErrors.username = "Username invalide (minimum 3 caractères)";
}
if (!formData.password) {
newErrors.password = 'Le mot de passe est requis';
} else if (formData.password.length < 6) {
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
}
if (!formData.password) {
newErrors.password = "Le mot de passe est requis";
} else if (formData.password.length < 6) {
newErrors.password =
"Le mot de passe doit contenir au moins 6 caractères";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
if (!validateForm()) return;
setIsLoading(true);
setApiError('');
setIsLoading(true);
setApiError("");
try {
console.log('🔐 [LOGIN] Appel API avec:', {
username: formData.username,
password: '***'
});
try {
console.log("🔐 [LOGIN] Appel API avec:", {
username: formData.username,
password: "***",
});
// ✅ Appeler la fonction de login sécurisée
const result = await loginUser(formData.username, formData.password);
// ✅ Appeler la fonction de login sécurisée
const result = await loginUser(
formData.username,
formData.password,
);
console.log('📋 [LOGIN] Résultat:', {
success: result.success,
message: result.message,
hasToken: !!result.access_token
});
console.log("📋 [LOGIN] Résultat:", {
success: result.success,
message: result.message,
hasToken: !!result.access_token,
});
if (result.success && result.access_token) {
console.log('✅ [LOGIN] Connexion réussie!');
if (result.success && result.access_token) {
console.log("✅ [LOGIN] Connexion réussie!");
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser()
// Pas besoin de le faire ici
console.log('✅ [LOGIN] Token et username synchronisés');
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser()
// Pas besoin de le faire ici
console.log("✅ [LOGIN] Token et username synchronisés");
// ✅ Vérifier la synchronisation
const syncedUsername = syncUsernameFromJWT();
console.log('✅ [LOGIN] Username synchronisé:', syncedUsername);
// ✅ Vérifier la synchronisation
const syncedUsername = syncUsernameFromJWT();
console.log("✅ [LOGIN] Username synchronisé:", syncedUsername);
// ✅ Redirection
console.log('✅ [LOGIN] Redirection vers /user/accueil');
navigate('/user/accueil');
} else {
// ❌ Erreur API
const errorMessage = result.message || 'Identifiants incorrects';
console.error('❌ [LOGIN] Erreur API:', errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error('💥 [LOGIN] Erreur catch:', err);
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion';
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
// ✅ Redirection
if (result.user?.must_change_password) {
console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis");
navigate("/user/change-password");
} else {
console.log("✅ [LOGIN] Redirection vers /user/accueil");
navigate("/user/accueil");
}
} else {
// ❌ Erreur API
const errorMessage =
result.message || "Identifiants incorrects";
console.error("❌ [LOGIN] Erreur API:", errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error("💥 [LOGIN] Erreur catch:", err);
const errorMessage =
err instanceof Error ? err.message : "Erreur de connexion";
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors((prev) => ({
...prev,
[name]: undefined,
}));
}
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError('');
}
};
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError("");
}
};
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<User className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Connexion Client</h1>
<p className="login-subtitle">Accédez à votre espace personnel</p>
</div>
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<User className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Connexion Client</h1>
<p className="login-subtitle">
Accédez à votre espace personnel
</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handleSubmit}>
<div className="login-card">
<form className="login-form" onSubmit={handleSubmit}>
{/* ✅ Erreur API globale */}
{apiError && (
<div
className="error-banner"
style={{
padding: "12px",
marginBottom: "16px",
backgroundColor: "#fee",
borderLeft: "4px solid #f44",
borderRadius: "4px",
color: "#c33",
}}
>
{apiError}
</div>
)}
{/* ✅ Erreur API globale */}
{apiError && (
<div className="error-banner" style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fee',
borderLeft: '4px solid #f44',
borderRadius: '4px',
color: '#c33'
}}>
{apiError}
</div>
)}
{/* Username / Email */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Username
</label>
<div className="input-wrapper">
<Mail
className="input-icon"
style={{ width: "20px", height: "20px" }}
/>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? "error" : ""}`}
placeholder="Votre username"
style={{ paddingLeft: "3rem" }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && (
<p className="error-message">
{errors.username}
</p>
)}
</div>
{/* Username / Email */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Username
</label>
<div className="input-wrapper">
<Mail className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Votre username"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && (
<p className="error-message">{errors.username}</p>
)}
{/* Password */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock
className="input-icon"
style={{ width: "20px", height: "20px" }}
/>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? "error" : ""}`}
placeholder="••••••••"
style={{
paddingLeft: "3rem",
paddingRight: "3rem",
}}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() =>
setShowPassword(!showPassword)
}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff
style={{
width: "20px",
height: "20px",
}}
/>
) : (
<Eye
style={{
width: "20px",
height: "20px",
}}
/>
)}
</button>
</div>
{errors.password && (
<p className="error-message">
{errors.password}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? "not-allowed" : "pointer",
}}
>
{isLoading ? (
<span className="loading-spinner">
Connexion en cours...
</span>
) : (
"Se connecter"
)}
</button>
</form>
</div>
</div>
{/* Password */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="••••••••"
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff style={{ width: '20px', height: '20px' }} />
) : (
<Eye style={{ width: '20px', height: '20px' }} />
)}
</button>
</div>
{errors.password && (
<p className="error-message">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? (
<span className="loading-spinner">
Connexion en cours...
</span>
) : (
'Se connecter'
)}
</button>
</form>
{/* Signup Link */}
<div className="signup-section">
<p className="signup-text">
Pas encore de compte ?{' '}
<button
type="button"
onClick={() => navigate('/register/client')}
className="signup-link"
>
Créer un compte
</button>
</p>
</div>
</div>
</div>
</div>
);
);
};
export default LoginClient;
export default LoginClient;
@@ -1,262 +0,0 @@
.login-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.login-content {
width: 100%;
max-width: 28rem;
position: relative;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
border-radius: 1rem;
margin-bottom: 1rem;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.login-title {
font-size: 1.875rem;
font-weight: bold;
color: white;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: #9ca3af;
}
.login-card {
background-color: #000000;
backdrop-filter: blur(16px);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(75, 85, 99, 0.5);
padding: 2rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
pointer-events: none;
color: #6b7280;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
background-color: #000000;
border: 1px solid #4b5563;
border-radius: 0.5rem;
color: white;
font-size: 1rem;
transition: all 0.3s;
}
.form-input::placeholder {
color: #6b7280;
}
.form-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
0 0 20px rgba(124, 58, 237, 0.3),
0 0 30px rgba(124, 58, 237, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.password-toggle:hover {
color: #d1d5db;
}
.error-message {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #f87171;
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
}
.remember-me {
display: flex;
align-items: center;
cursor: pointer;
}
.remember-me input {
width: 1rem;
height: 1rem;
background-color: #111827;
border: 1px solid #4b5563;
border-radius: 0.25rem;
cursor: pointer;
accent-color: #7c3aed;
}
.remember-me span {
margin-left: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.forgot-password {
background: transparent;
border: none;
font-size: 0.875rem;
color: #a78bfa;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.forgot-password:hover {
color: #c4b5fd;
}
.submit-button {
width: 100%;
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.submit-button:hover:not(:disabled) {
background: linear-gradient(to right, #6d28d9, #5b21b6);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 1s linear infinite;
margin-right: 0.75rem;
height: 1.25rem;
width: 1.25rem;
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.signup-section {
margin-top: 1.5rem;
text-align: center;
}
.signup-text {
color: #9ca3af;
font-size: 0.875rem;
}
.signup-link {
background: transparent;
border: none;
color: #a78bfa;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.signup-link:hover {
color: #c4b5fd;
}
.login-footer {
margin-top: 2rem;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
@@ -1,269 +0,0 @@
import { useState } from 'react';
import { Lock, User, Eye, EyeOff, Truck } from 'lucide-react';
import './LoginLivreur.css';
import { loginAdmin, syncAdminUsernameFromJWT, extractAdminRoleFromToken } from '../../api/api_admin';
import { useNavigate } from 'react-router-dom';
interface LoginRequest {
username: string;
password: string;
}
const LoginLivreur = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<LoginRequest>({
username: '',
password: ''
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>('');
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: { username?: string; password?: string } = {};
console.log('🔍 [LIVREUR_LOGIN] Validation - Username:', `"${formData.username}"`);
console.log('🔍 [LIVREUR_LOGIN] Après trim:', `"${formData.username.trim()}"`);
if (!formData.username.trim()) {
newErrors.username = 'L\'username est requis';
} else if (formData.username.trim().length < 3) {
newErrors.username = 'Username invalide (minimum 3 caractères)';
}
if (!formData.password) {
newErrors.password = 'Le mot de passe est requis';
} else if (formData.password.length < 6) {
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setApiError('');
try {
console.log('🔐 [LIVREUR_LOGIN] Appel API avec:', {
username: formData.username,
password: '***'
});
// ✅ Appeler la fonction de login (utilise le même endpoint que admin)
const result = await loginAdmin(formData.username, formData.password);
console.log('📋 [LIVREUR_LOGIN] Résultat:', {
success: result.success,
message: result.message,
hasToken: !!result.access_token,
role: result.user?.role
});
if (result.success && result.access_token) {
console.log('✅ [LIVREUR_LOGIN] Connexion réussie!');
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
console.log('✅ [LIVREUR_LOGIN] Token et username synchronisés');
// ✅ Vérifier la synchronisation
const syncedUsername = syncAdminUsernameFromJWT();
console.log('✅ [LIVREUR_LOGIN] Username synchronisé:', syncedUsername);
// ✅ Extraire le rôle pour rediriger correctement
const role = extractAdminRoleFromToken();
console.log('✅ [LIVREUR_LOGIN] Rôle détecté:', role);
// ✅ Redirection selon le rôle
if (role === 'livreur') {
console.log('✅ [LIVREUR_LOGIN] Redirection vers /livreur/dashboard');
navigate('/delivery/dashboard');
} else if (role === 'admin') {
console.log('✅ [LIVREUR_LOGIN] Redirection vers /admin/dashboard');
navigate('/admin/dashboard');
} else if (role === 'cabine') {
console.log('✅ [LIVREUR_LOGIN] Redirection vers /cabine/dashboard');
navigate('/cabine/dashboard');
} else {
console.warn('⚠️ [LIVREUR_LOGIN] Rôle inconnu, redirection par défaut');
navigate('/delivery/dashboard');
}
} else {
// ❌ Erreur API
const errorMessage = result.message || 'Identifiants incorrects';
console.error('❌ [LIVREUR_LOGIN] Erreur API:', errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error('💥 [LIVREUR_LOGIN] Erreur catch:', err);
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion';
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError('');
}
};
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<Truck className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Connexion Livreur</h1>
<p className="login-subtitle">Accédez à votre espace de livraison</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handleSubmit}>
{/* ✅ Erreur API globale */}
{apiError && (
<div className="error-banner" style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fee',
borderLeft: '4px solid #f44',
borderRadius: '4px',
color: '#c33'
}}>
{apiError}
</div>
)}
{/* Username */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Nom d'utilisateur
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Votre username"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && (
<p className="error-message">{errors.username}</p>
)}
</div>
{/* Password */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="••••••••"
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff style={{ width: '20px', height: '20px' }} />
) : (
<Eye style={{ width: '20px', height: '20px' }} />
)}
</button>
</div>
{errors.password && (
<p className="error-message">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? (
<span className="loading-spinner">
Connexion en cours...
</span>
) : (
'Se connecter'
)}
</button>
</form>
{/* Info */}
<div className="signup-section">
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
Espace réservé aux livreurs
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginLivreur;
@@ -1,262 +0,0 @@
.login-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.login-content {
width: 100%;
max-width: 28rem;
position: relative;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
border-radius: 1rem;
margin-bottom: 1rem;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.login-title {
font-size: 1.875rem;
font-weight: bold;
color: white;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: #9ca3af;
}
.login-card {
background-color: #000000;
backdrop-filter: blur(16px);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(75, 85, 99, 0.5);
padding: 2rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
pointer-events: none;
color: #6b7280;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
background-color: #000000;
border: 1px solid #4b5563;
border-radius: 0.5rem;
color: white;
font-size: 1rem;
transition: all 0.3s;
}
.form-input::placeholder {
color: #6b7280;
}
.form-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
0 0 20px rgba(124, 58, 237, 0.3),
0 0 30px rgba(124, 58, 237, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.password-toggle:hover {
color: #d1d5db;
}
.error-message {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #f87171;
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
}
.remember-me {
display: flex;
align-items: center;
cursor: pointer;
}
.remember-me input {
width: 1rem;
height: 1rem;
background-color: #111827;
border: 1px solid #4b5563;
border-radius: 0.25rem;
cursor: pointer;
accent-color: #7c3aed;
}
.remember-me span {
margin-left: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.forgot-password {
background: transparent;
border: none;
font-size: 0.875rem;
color: #a78bfa;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.forgot-password:hover {
color: #c4b5fd;
}
.submit-button {
width: 100%;
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.submit-button:hover:not(:disabled) {
background: linear-gradient(to right, #6d28d9, #5b21b6);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 1s linear infinite;
margin-right: 0.75rem;
height: 1.25rem;
width: 1.25rem;
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.signup-section {
margin-top: 1.5rem;
text-align: center;
}
.signup-text {
color: #9ca3af;
font-size: 0.875rem;
}
.signup-link {
background: transparent;
border: none;
color: #a78bfa;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.signup-link:hover {
color: #c4b5fd;
}
.login-footer {
margin-top: 2rem;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
@@ -1,261 +0,0 @@
import { useState } from 'react';
import { Lock, User, Eye, EyeOff, MessageCircle } from 'lucide-react';
import './LoginPage.css';
import { loginAdmin, syncAdminUsernameFromJWT } from '../../api/api_admin';
import { useNavigate } from 'react-router-dom';
interface AdminLoginRequest {
username: string;
password: string;
}
const LoginCabine = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<AdminLoginRequest>({
username: '',
password: ''
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>('');
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: { username?: string; password?: string } = {};
console.log('🔍 [ADMIN_LOGIN] Validation - Username:', `"${formData.username}"`);
console.log('🔍 [ADMIN_LOGIN] Après trim:', `"${formData.username.trim()}"`);
if (!formData.username.trim()) {
newErrors.username = 'L\'username admin est requis';
} else if (formData.username.trim().length < 3) {
newErrors.username = 'Username invalide (minimum 3 caractères)';
}
if (!formData.password) {
newErrors.password = 'Le mot de passe est requis';
} else if (formData.password.length < 6) {
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setApiError('');
try {
console.log('🔐 [ADMIN_LOGIN] Appel API avec:', {
username: formData.username,
password: '***'
});
// ✅ Appeler la fonction de login admin sécurisée
const result = await loginAdmin(formData.username, formData.password);
console.log('📋 [ADMIN_LOGIN] Résultat:', {
success: result.success,
message: result.message,
hasToken: !!result.access_token,
role: result.user?.role
});
if (result.success && result.access_token) {
console.log('✅ [ADMIN_LOGIN] Connexion admin réussie!');
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
// Pas besoin de le faire ici
console.log('✅ [ADMIN_LOGIN] Token et username admin synchronisés');
// ✅ Vérifier la synchronisation
const syncedUsername = syncAdminUsernameFromJWT();
console.log('✅ [ADMIN_LOGIN] Username admin synchronisé:', syncedUsername);
// ✅ Redirection vers dashboard admin
console.log('✅ [ADMIN_LOGIN] Redirection vers /admin/dashboard');
navigate('/cabine/dashboard');
} else {
// ❌ Erreur API
const errorMessage = result.message || 'Identifiants admin incorrects';
console.error('❌ [ADMIN_LOGIN] Erreur API:', errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error('💥 [ADMIN_LOGIN] Erreur catch:', err);
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion admin';
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError('');
}
};
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<MessageCircle className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Connexion cabine</h1>
<p className="login-subtitle">Accédez au panneau cabine</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handleSubmit}>
{/* ✅ Erreur API globale */}
{apiError && (
<div className="error-banner" style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fee',
borderLeft: '4px solid #f44',
borderRadius: '4px',
color: '#c33'
}}>
{apiError}
</div>
)}
{/* Username Admin */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Username Administrateur
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Username admin"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && (
<p className="error-message">{errors.username}</p>
)}
</div>
{/* Password */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="••••••••"
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff style={{ width: '20px', height: '20px' }} />
) : (
<Eye style={{ width: '20px', height: '20px' }} />
)}
</button>
</div>
{errors.password && (
<p className="error-message">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? (
<span className="loading-spinner">
Connexion en cours...
</span>
) : (
'Se connecter'
)}
</button>
</form>
{/* Info sécurité */}
<div className="signup-section">
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
Accès réservé aux administrateurs
</p>
</div>
</div>
{/* Footer */}
<div className="login-footer">
<p>
Besoin d'aide ? Contactez le support technique
</p>
</div>
</div>
</div>
);
};
export default LoginCabine;
@@ -1,221 +0,0 @@
.register-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #1a1a1a;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.register-content {
width: 100%;
max-width: 28rem;
position: relative;
padding: 2rem 0;
margin: auto 0;
}
.register-header {
text-align: center;
margin-bottom: 1.5rem;
}
.register-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
border-radius: 1rem;
margin-bottom: 1rem;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
}
.register-title {
font-size: 1.875rem;
font-weight: bold;
color: white;
margin-bottom: 0.5rem;
}
.register-subtitle {
color: #9ca3af;
}
.register-card {
background-color: #000000;
backdrop-filter: blur(16px);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(75, 85, 99, 0.5);
padding: 2rem;
}
.register-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
pointer-events: none;
color: #6b7280;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
background-color: #000000;
border: 1px solid #4b5563;
border-radius: 0.5rem;
color: white;
font-size: 1rem;
transition: all 0.3s;
}
.form-input::placeholder {
color: #6b7280;
}
.form-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
0 0 20px rgba(124, 58, 237, 0.3),
0 0 30px rgba(124, 58, 237, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.password-toggle:hover {
color: #d1d5db;
}
.error-message {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #f87171;
}
.submit-button {
width: 100%;
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
margin-top: 0.5rem;
}
.submit-button:hover:not(:disabled) {
background: linear-gradient(to right, #6d28d9, #5b21b6);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 1s linear infinite;
margin-right: 0.75rem;
height: 1.25rem;
width: 1.25rem;
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.signup-section {
margin-top: 1.5rem;
text-align: center;
}
.signup-text {
color: #9ca3af;
font-size: 0.875rem;
}
.signup-link {
background: transparent;
border: none;
color: #a78bfa;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.signup-link:hover {
color: #c4b5fd;
}
/* Media queries pour mobile */
@media (max-height: 800px) {
.register-content {
padding: 1rem 0;
}
.register-header {
margin-bottom: 1rem;
}
.register-form {
gap: 0.875rem;
}
.register-card {
padding: 1.5rem;
}
}
@media (max-height: 700px) {
.register-logo {
width: 3rem;
height: 3rem;
}
.register-title {
font-size: 1.5rem;
}
.form-input {
padding: 0.625rem 1rem 0.625rem 3rem;
}
}
@@ -1,379 +0,0 @@
import { useState } from 'react';
import { Lock, Eye, EyeOff, ShoppingBag, User, Phone } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import './Register.css';
import { registerUser, syncUsernameFromJWT } from '../../api/api';
import type { RegisterData, RegisterFormValidation } from '../../api/api_types';
const Register = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<RegisterData>({
nom: '',
prenom: '',
telephone: '',
username: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<RegisterFormValidation['errors']>({});
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>('');
/**
* Valider le formulaire
*/
const validateForm = (): boolean => {
const newErrors: RegisterFormValidation['errors'] = {};
// Valider nom
if (!formData.nom.trim()) {
newErrors.nom = 'Le nom est requis';
} else if (formData.nom.trim().length < 2) {
newErrors.nom = 'Le nom doit contenir au moins 2 caractères';
}
// Valider prénom
if (!formData.prenom.trim()) {
newErrors.prenom = 'Le prénom est requis';
} else if (formData.prenom.trim().length < 2) {
newErrors.prenom = 'Le prénom doit contenir au moins 2 caractères';
}
// Valider téléphone
if (!formData.telephone.trim()) {
newErrors.telephone = 'Le numéro de téléphone est requis';
} else {
const cleanPhone = formData.telephone.replace(/\s/g, '');
if (!/^[0-9+]{10,15}$/.test(cleanPhone)) {
newErrors.telephone = 'Numéro de téléphone invalide (10-15 chiffres)';
}
}
// Valider username
if (!formData.username.trim()) {
newErrors.username = 'Le nom d\'utilisateur est requis';
} else if (formData.username.trim().length < 3) {
newErrors.username = 'Le nom d\'utilisateur doit contenir au moins 3 caractères';
} else if (formData.username.trim().length > 50) {
newErrors.username = 'Le nom d\'utilisateur ne doit pas dépasser 50 caractères';
}
// Valider mot de passe
if (!formData.password) {
newErrors.password = 'Le mot de passe est requis';
} else if (formData.password.length < 8) {
newErrors.password = 'Le mot de passe doit contenir au moins 8 caractères';
} else if (formData.password.length > 128) {
newErrors.password = 'Le mot de passe ne doit pas dépasser 128 caractères';
}
console.log('🔍 [REGISTER] Erreurs validation:', newErrors);
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Soumettre le formulaire
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setApiError('');
try {
console.log('📝 [REGISTER] Inscription avec:', {
username: formData.username,
nom: formData.nom,
prenom: formData.prenom,
telephone: formData.telephone,
password: '***'
});
// ✅ Appeler la fonction d'enregistrement sécurisée
const result = await registerUser(
formData.username,
formData.password,
formData.nom,
formData.prenom,
formData.telephone
);
console.log('📋 [REGISTER] Résultat:', {
success: result.success,
message: result.message,
hasToken: !!result.access_token
});
if (result.success && result.access_token) {
console.log('✅ [REGISTER] Inscription réussie!');
// ✅ La synchronisation JWT est AUTOMATIQUE dans registerUser()
// Pas besoin de le faire ici
console.log('✅ [REGISTER] Token et username synchronisés');
// ✅ Vérifier la synchronisation
const syncedUsername = syncUsernameFromJWT();
console.log('✅ [REGISTER] Username synchronisé:', syncedUsername);
// ✅ Redirection vers accueil (utilisateur déjà connecté)
console.log('✅ [REGISTER] Redirection vers /user/accueil');
navigate('/user/accueil');
} else {
// ❌ Erreur API
const errorMessage = result.message || 'Erreur lors de l\'inscription';
console.error('❌ [REGISTER] Erreur API:', errorMessage);
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
console.error('💥 [REGISTER] Erreur catch:', err);
const errorMessage = err instanceof Error ? err.message : 'Erreur serveur';
setApiError(errorMessage);
setErrors({ username: errorMessage });
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Effacer l'erreur de ce champ
if (errors[name as keyof typeof errors]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
// Effacer l'erreur API si l'utilisateur recommence à saisir
if (apiError) {
setApiError('');
}
};
return (
<div className="register-container">
<div className="register-content">
<div className="register-header">
<div className="register-logo">
<ShoppingBag className="w-8 h-8 text-white" />
</div>
<h1 className="register-title">Créer un compte</h1>
<p className="register-subtitle">Rejoignez-nous dès maintenant</p>
</div>
<div className="register-card">
<form className="register-form" onSubmit={handleSubmit}>
{/* ✅ Erreur API globale */}
{apiError && (
<div className="error-banner" style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fee',
borderLeft: '4px solid #f44',
borderRadius: '4px',
color: '#c33'
}}>
{apiError}
</div>
)}
{/* Nom */}
<div className="form-group">
<label htmlFor="nom" className="form-label">
Nom
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="nom"
name="nom"
value={formData.nom}
onChange={handleChange}
className={`form-input ${errors.nom ? 'error' : ''}`}
placeholder="Votre nom"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
/>
</div>
{errors.nom && <p className="error-message">{errors.nom}</p>}
</div>
{/* Prénom */}
<div className="form-group">
<label htmlFor="prenom" className="form-label">
Prénom
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="prenom"
name="prenom"
value={formData.prenom}
onChange={handleChange}
className={`form-input ${errors.prenom ? 'error' : ''}`}
placeholder="Votre prénom"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
/>
</div>
{errors.prenom && <p className="error-message">{errors.prenom}</p>}
</div>
{/* Téléphone */}
<div className="form-group">
<label htmlFor="telephone" className="form-label">
Numéro de téléphone
</label>
<div className="input-wrapper">
<Phone className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="tel"
id="telephone"
name="telephone"
value={formData.telephone}
onChange={handleChange}
className={`form-input ${errors.telephone ? 'error' : ''}`}
placeholder="0123456789 ou +33123456789"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
/>
</div>
{errors.telephone && <p className="error-message">{errors.telephone}</p>}
</div>
{/* Username */}
<div className="form-group">
<label htmlFor="username" className="form-label">
Nom d'utilisateur
</label>
<div className="input-wrapper">
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Votre nom d'utilisateur"
style={{ paddingLeft: '3rem' }}
disabled={isLoading}
autoComplete="username"
/>
</div>
{errors.username && <p className="error-message">{errors.username}</p>}
</div>
{/* Mot de passe */}
<div className="form-group">
<label htmlFor="password" className="form-label">
Mot de passe
</label>
<div className="input-wrapper">
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="••••••••"
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
disabled={isLoading}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
disabled={isLoading}
>
{showPassword ? (
<EyeOff style={{ width: '20px', height: '20px' }} />
) : (
<Eye style={{ width: '20px', height: '20px' }} />
)}
</button>
</div>
{errors.password && <p className="error-message">{errors.password}</p>}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="submit-button"
style={{
opacity: isLoading ? 0.6 : 1,
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? (
<span className="loading-spinner">
<svg
className="spinner"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ width: '16px', height: '16px', marginRight: '8px' }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Création en cours...
</span>
) : (
'Créer mon compte'
)}
</button>
</form>
{/* Login Link */}
<div className="signup-section">
<p className="signup-text">
Vous avez déjà un compte ?{' '}
<button
type="button"
onClick={() => navigate('/login/client')}
className="signup-link"
>
Se connecter
</button>
</p>
</div>
</div>
</div>
</div>
);
};
export default Register;