chore: add create user route
This commit is contained in:
@@ -68,8 +68,7 @@ func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
|||||||
func (d *Database) DeleteAlertPolicy(id int) error {
|
func (d *Database) DeleteAlertPolicy(id int) error {
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE alerte_policy
|
DELETE FROM alerte_policy
|
||||||
SET status = 'false', updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
_, err := d.Exec(query, id)
|
_, err := d.Exec(query, id)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: gestion_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- gestion-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: gestion_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"redis-server",
|
||||||
|
"--appendonly",
|
||||||
|
"yes",
|
||||||
|
"--requirepass",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
]
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT}:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- gestion-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gestion-net:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
@@ -46,7 +46,11 @@ func DeleteAlert(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "livreur" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
err = database.DeleteAlertPolicy(alertID)
|
err = database.DeleteAlertPolicy(alertID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -68,7 +72,11 @@ func GetAlert(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
alert, err := database.GetAlertPolicy(alertID)
|
alert, err := database.GetAlertPolicy(alertID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -607,8 +607,14 @@ func HealthCheck(c *gin.Context) {
|
|||||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||||
func GetAllUsers(c *gin.Context) {
|
func GetAllUsers(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
|
||||||
|
if userRole != "admin" && userRole != "cabine" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
users, err := database.GetAllUsers()
|
users, err := database.GetAllUsers()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||||
@@ -634,7 +640,11 @@ func GetAllUsers(c *gin.Context) {
|
|||||||
|
|
||||||
func GetAllDeliveryMen(c *gin.Context) {
|
func GetAllDeliveryMen(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "cabine" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
users, err := database.GetAllDeliveryMen()
|
users, err := database.GetAllDeliveryMen()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||||
@@ -662,7 +672,11 @@ func GetAllDeliveryMen(c *gin.Context) {
|
|||||||
// GET /api/v1/admin/clients
|
// GET /api/v1/admin/clients
|
||||||
func GetAllClients(c *gin.Context) {
|
func GetAllClients(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "cabine" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
clients, err := database.GetAllClients()
|
clients, err := database.GetAllClients()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
|
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
|
||||||
@@ -705,7 +719,11 @@ func DeleteUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
err = database.DeleteUser(id)
|
err = database.DeleteUser(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
|
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
|
||||||
@@ -728,7 +746,11 @@ func DeleteClient(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "cabine" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
err = database.DeleteClient(id)
|
err = database.DeleteClient(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
|
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
|
||||||
@@ -739,3 +761,28 @@ func DeleteClient(c *gin.Context) {
|
|||||||
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
|
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
|
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateUser(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := c.ShouldBindJSON(&user); err != nil {
|
||||||
|
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if userRole != "cabine" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := database.CreateUser(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
adminGroupV2.PUT("/users/:id", handlers.UpdateUserByAdmin) // ✅ Modifier un user
|
adminGroupV2.PUT("/users/:id", handlers.UpdateUserByAdmin) // ✅ Modifier un user
|
||||||
adminGroupV2.DELETE("/clients/:id", handlers.DeleteClient)
|
adminGroupV2.DELETE("/clients/:id", handlers.DeleteClient)
|
||||||
adminGroupV2.DELETE("/users/:id", handlers.DeleteUser)
|
adminGroupV2.DELETE("/users/:id", handlers.DeleteUser)
|
||||||
|
adminGroupV2.POST("/users", handlers.CreateUser)
|
||||||
// Get location livreur
|
// Get location livreur
|
||||||
adminGroupV2.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
adminGroupV2.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// ✅ loginUser et registerUser retournent AuthResponse
|
// ✅ loginUser et registerUser retournent AuthResponse
|
||||||
// ✅ sessionStorage (pas localStorage)
|
// ✅ sessionStorage (pas localStorage)
|
||||||
|
|
||||||
const API_URL = "/api/v1";
|
const API_URL = "http://localhost:8080/api/v1";
|
||||||
import type {
|
import type {
|
||||||
ConfirmReceptionResponse,
|
ConfirmReceptionResponse,
|
||||||
CheckoutCartResponse,
|
CheckoutCartResponse,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
DeliveryPersonDetails,
|
DeliveryPersonDetails,
|
||||||
DeliveryPersonStats,
|
DeliveryPersonStats,
|
||||||
} from "./api_admin_types";
|
} from "./api_admin_types";
|
||||||
const API_URL = "/api/v2";
|
const API_URL = "http://localhost:8080/api/v2";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🔐 TYPES - ADMIN
|
// 🔐 TYPES - ADMIN
|
||||||
@@ -2583,6 +2583,50 @@ export const deleteAlertAdmin = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CreateUser = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
role: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = sessionStorage.getItem("admin_token");
|
||||||
|
const response = await fetch(`${API_URL}/admin/protected/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("❌ [CREATE_USER] Erreur API:", data);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.error || "Erreur création utilisateur",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ [CREATE_USER] Utilisateur créé avec succès");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.message || "Utilisateur créé avec succès",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [CREATE_USER] Erreur fetch:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Erreur réseau inconnue",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🔄 EXPORT PAR DÉFAUT
|
// 🔄 EXPORT PAR DÉFAUT
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -2592,6 +2636,7 @@ export default {
|
|||||||
registerAdmin,
|
registerAdmin,
|
||||||
loginAdmin,
|
loginAdmin,
|
||||||
logoutAdmin,
|
logoutAdmin,
|
||||||
|
CreateUser,
|
||||||
|
|
||||||
// JWT Utils
|
// JWT Utils
|
||||||
extractAdminUsernameFromToken,
|
extractAdminUsernameFromToken,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// ✅ Utilise /api/v1/cabine/* endpoints uniquement
|
// ✅ Utilise /api/v1/cabine/* endpoints uniquement
|
||||||
// ✅ sessionStorage pour la persistance
|
// ✅ sessionStorage pour la persistance
|
||||||
|
|
||||||
const API_URL = "/api/v1/cabine";
|
const API_URL = "http://localhost:8080/api/v1/cabine";
|
||||||
import type {
|
import type {
|
||||||
DeliveryPerson,
|
DeliveryPerson,
|
||||||
DeliveryPersonsStats,
|
DeliveryPersonsStats,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// ✅ Fonctions helper pour le dashboard livreur
|
// ✅ Fonctions helper pour le dashboard livreur
|
||||||
// ✅ Utilise /api/v1/livreur/* endpoints
|
// ✅ Utilise /api/v1/livreur/* endpoints
|
||||||
|
|
||||||
const API_URL = "/api/v1/livreur";
|
const API_URL = "http://localhost:8080/api/v1/livreur";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🔐 TYPES - LIVREUR
|
// 🔐 TYPES - LIVREUR
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// ✅ Galerie de médias (images/vidéos)
|
// ✅ Galerie de médias (images/vidéos)
|
||||||
// ✅ Informations complètes
|
// ✅ Informations complètes
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Package,
|
Package,
|
||||||
@@ -16,17 +16,20 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Video as VideoIcon,
|
Video as VideoIcon,
|
||||||
Play
|
Play,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
import type { Product } from '../api/api_admin_types'; // ✅ CORRIGÉ
|
import type { Product } from "../api/api_admin_types"; // ✅ CORRIGÉ
|
||||||
import './ProductModal.css';
|
import "./ProductModal.css";
|
||||||
|
|
||||||
interface ProductDetailsModalProps {
|
interface ProductDetailsModalProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onClose }) => {
|
const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
|
||||||
|
product,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📝 STATE
|
// 📝 STATE
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -37,30 +40,39 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
// ============================================
|
// ============================================
|
||||||
const getCategoryLabel = (category: string): string => {
|
const getCategoryLabel = (category: string): string => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'weed&hash': return 'Weed & Hash';
|
case "weed&hash":
|
||||||
case 'zipette&co': return 'Zipette & Co';
|
return "Weed & Hash";
|
||||||
case 'gros&semi': return 'Gros & Semi';
|
case "zipette&co":
|
||||||
default: return category;
|
return "Zipette & Co";
|
||||||
|
case "gros&semi":
|
||||||
|
return "Gros & Semi";
|
||||||
|
default:
|
||||||
|
return category;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString?: string): string => {
|
const formatDate = (dateString?: string): string => {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return "N/A";
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('fr-FR', {
|
return date.toLocaleDateString("fr-FR", {
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
month: 'long',
|
month: "long",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit'
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMedia = product.media && product.media.length > 0;
|
const hasMedia = product.media && product.media.length > 0;
|
||||||
const currentMedia = hasMedia && product.media ? product.media[currentMediaIndex] : null;
|
const currentMedia =
|
||||||
|
hasMedia && product.media ? product.media[currentMediaIndex] : null;
|
||||||
|
|
||||||
const nextMedia = () => {
|
const nextMedia = () => {
|
||||||
if (hasMedia && product.media && currentMediaIndex < product.media.length - 1) {
|
if (
|
||||||
|
hasMedia &&
|
||||||
|
product.media &&
|
||||||
|
currentMediaIndex < product.media.length - 1
|
||||||
|
) {
|
||||||
setCurrentMediaIndex(currentMediaIndex + 1);
|
setCurrentMediaIndex(currentMediaIndex + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -84,7 +96,9 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<Package size={24} className="header-icon" />
|
<Package size={24} className="header-icon" />
|
||||||
<div>
|
<div>
|
||||||
<h2>{product.name}</h2>
|
<h2>{product.name}</h2>
|
||||||
<p className="modal-subtitle">{getCategoryLabel(product.category)}</p>
|
<p className="modal-subtitle">
|
||||||
|
{getCategoryLabel(product.category)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="close-modal" onClick={onClose}>
|
<button className="close-modal" onClick={onClose}>
|
||||||
@@ -98,20 +112,21 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
{hasMedia && (
|
{hasMedia && (
|
||||||
<div className="media-gallery">
|
<div className="media-gallery">
|
||||||
<div className="media-viewer">
|
<div className="media-viewer">
|
||||||
{currentMedia?.type === 'image' ? (
|
{currentMedia?.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={`http://localhost:8080${currentMedia.url}`}
|
src={`http://localhost:8080${currentMedia.url}`}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="media-display"
|
className="media-display"
|
||||||
/>
|
/>
|
||||||
) : currentMedia?.type === 'video' ? (
|
) : currentMedia?.type === "video" ? (
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
<video
|
<video
|
||||||
src={`http://localhost:8080${currentMedia.url}`}
|
src={`http://localhost:8080${currentMedia.url}`}
|
||||||
controls
|
controls
|
||||||
className="media-display"
|
className="media-display"
|
||||||
>
|
>
|
||||||
Votre navigateur ne supporte pas la lecture de vidéos.
|
Votre navigateur ne supporte pas la
|
||||||
|
lecture de vidéos.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -134,7 +149,10 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<button
|
<button
|
||||||
className="media-nav next"
|
className="media-nav next"
|
||||||
onClick={nextMedia}
|
onClick={nextMedia}
|
||||||
disabled={currentMediaIndex === product.media.length - 1}
|
disabled={
|
||||||
|
currentMediaIndex ===
|
||||||
|
product.media.length - 1
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ChevronRight size={24} />
|
<ChevronRight size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -143,7 +161,8 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
|
|
||||||
{/* Compteur */}
|
{/* Compteur */}
|
||||||
<div className="media-counter">
|
<div className="media-counter">
|
||||||
{currentMediaIndex + 1} / {product.media?.length || 0}
|
{currentMediaIndex + 1} /{" "}
|
||||||
|
{product.media?.length || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,10 +172,12 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
{product.media.map((media, index) => (
|
{product.media.map((media, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={`thumbnail ${index === currentMediaIndex ? 'active' : ''}`}
|
className={`thumbnail ${index === currentMediaIndex ? "active" : ""}`}
|
||||||
onClick={() => setCurrentMediaIndex(index)}
|
onClick={() =>
|
||||||
|
setCurrentMediaIndex(index)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{media.type === 'image' ? (
|
{media.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={`http://localhost:8080${media.url}`}
|
src={`http://localhost:8080${media.url}`}
|
||||||
alt={`Media ${index + 1}`}
|
alt={`Media ${index + 1}`}
|
||||||
@@ -189,7 +210,9 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<Package size={18} />
|
<Package size={18} />
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<p className="detail-description">{product.description}</p>
|
<p className="detail-description">
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stock */}
|
{/* Stock */}
|
||||||
@@ -198,7 +221,9 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<Box size={18} />
|
<Box size={18} />
|
||||||
Stock Disponible
|
Stock Disponible
|
||||||
</h3>
|
</h3>
|
||||||
<div className={`stock-badge ${product.stock > 0 ? 'in-stock' : 'out-of-stock'}`}>
|
<div
|
||||||
|
className={`stock-badge ${product.stock > 0 ? "in-stock" : "out-of-stock"}`}
|
||||||
|
>
|
||||||
{product.stock}g
|
{product.stock}g
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,18 +248,27 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<div className="prices-table">
|
<div className="prices-table">
|
||||||
{product.prices && product.prices.length > 0 ? (
|
{product.prices && product.prices.length > 0 ? (
|
||||||
product.prices.map((price, index) => (
|
product.prices.map((price, index) => (
|
||||||
<div key={index} className="price-row-display">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="price-row-display"
|
||||||
|
>
|
||||||
<div className="price-quantity">
|
<div className="price-quantity">
|
||||||
<span className="quantity-value">{price.quantity}g</span>
|
<span className="quantity-value">
|
||||||
|
{price.quantity}g
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="price-arrow">→</div>
|
<div className="price-arrow">→</div>
|
||||||
<div className="price-amount">
|
<div className="price-amount">
|
||||||
<span className="amount-value">{price.price.toFixed(2)}€</span>
|
<span className="amount-value">
|
||||||
|
{price.price.toFixed(2)}€
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="no-data">Aucun tarif défini</p>
|
<p className="no-data">
|
||||||
|
Aucun tarif défini
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,11 +283,35 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<div className="media-stats">
|
<div className="media-stats">
|
||||||
<div className="media-stat">
|
<div className="media-stat">
|
||||||
<ImageIcon size={20} />
|
<ImageIcon size={20} />
|
||||||
<span>{product.media.filter(m => m.type === 'image').length} Image{product.media.filter(m => m.type === 'image').length > 1 ? 's' : ''}</span>
|
<span>
|
||||||
|
{
|
||||||
|
product.media.filter(
|
||||||
|
(m) => m.type === "image",
|
||||||
|
).length
|
||||||
|
}{" "}
|
||||||
|
Image
|
||||||
|
{product.media.filter(
|
||||||
|
(m) => m.type === "image",
|
||||||
|
).length > 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-stat">
|
<div className="media-stat">
|
||||||
<VideoIcon size={20} />
|
<VideoIcon size={20} />
|
||||||
<span>{product.media.filter(m => m.type === 'video').length} Vidéo{product.media.filter(m => m.type === 'video').length > 1 ? 's' : ''}</span>
|
<span>
|
||||||
|
{
|
||||||
|
product.media.filter(
|
||||||
|
(m) => m.type === "video",
|
||||||
|
).length
|
||||||
|
}{" "}
|
||||||
|
Vidéo
|
||||||
|
{product.media.filter(
|
||||||
|
(m) => m.type === "video",
|
||||||
|
).length > 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +324,9 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<Calendar size={18} />
|
<Calendar size={18} />
|
||||||
Créé le
|
Créé le
|
||||||
</h3>
|
</h3>
|
||||||
<p className="date-text">{formatDate(product.created_at)}</p>
|
<p className="date-text">
|
||||||
|
{formatDate(product.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -276,7 +336,9 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onCl
|
|||||||
<Calendar size={18} />
|
<Calendar size={18} />
|
||||||
Modifié le
|
Modifié le
|
||||||
</h3>
|
</h3>
|
||||||
<p className="date-text">{formatDate(product.updated_at)}</p>
|
<p className="date-text">
|
||||||
|
{formatDate(product.updated_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -377,21 +377,21 @@ function AdminAlerts() {
|
|||||||
|
|
||||||
<div className="alerts-filter-buttons">
|
<div className="alerts-filter-buttons">
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("all")}
|
onClick={() => setStatusFilter("all")}
|
||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
Toutes ({alerts.length})
|
Toutes ({alerts.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("true")}
|
onClick={() => setStatusFilter("true")}
|
||||||
>
|
>
|
||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
Actives ({stats.active})
|
Actives ({stats.active})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("false")}
|
onClick={() => setStatusFilter("false")}
|
||||||
>
|
>
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
|
|||||||
@@ -1291,3 +1291,156 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
⭐ BOUTON CRÉER UTILISATEUR
|
||||||
|
============================================ */
|
||||||
|
.create-user-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 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);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.create-user-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #6d28d9, #5b21b6);
|
||||||
|
border-color: #5b21b6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
.create-user-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
/* ============================================
|
||||||
|
⭐ MODAL CRÉATION UTILISATEUR
|
||||||
|
============================================ */
|
||||||
|
.create-user-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 550px;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: linear-gradient(145deg, #1f1f1f 0%, #0a0a0a 100%);
|
||||||
|
border: 2px solid #5b21b6;
|
||||||
|
border-radius: 24px;
|
||||||
|
z-index: 10000;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.5),
|
||||||
|
0 24px 80px rgba(16, 185, 129, 0.3),
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.8),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.create-user-modal .modal-header {
|
||||||
|
background: linear-gradient(135deg, #6d28d9, #5b21b6);
|
||||||
|
border-bottom: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
.create-user-modal .modal-header h2 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(20, 20, 20, 0.8),
|
||||||
|
rgba(10, 10, 10, 0.9)
|
||||||
|
);
|
||||||
|
border: 2px solid rgba(16, 185, 129, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-size: 20px;
|
||||||
|
padding-right: 3rem;
|
||||||
|
|
||||||
|
/* ✅ Force le style natif du navigateur */
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ STYLE DES OPTIONS - utiliser color-scheme */
|
||||||
|
.form-group select option {
|
||||||
|
background-color: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(25, 25, 25, 0.9),
|
||||||
|
rgba(15, 15, 15, 0.95)
|
||||||
|
);
|
||||||
|
border-color: #5b21b6;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 6px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 4px rgba(16, 185, 129, 0.15),
|
||||||
|
0 4px 16px rgba(16, 185, 129, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-size: 20px;
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.users-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.create-user-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.create-user-modal {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Leaf,
|
Leaf,
|
||||||
Wind,
|
Wind,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import AdminLayout from "../../components/AdminLayout";
|
import AdminLayout from "../../components/AdminLayout";
|
||||||
import "./AdminUsers.css";
|
import "./AdminUsers.css";
|
||||||
@@ -26,10 +27,12 @@ import {
|
|||||||
isAdminAuthenticated,
|
isAdminAuthenticated,
|
||||||
deleteUserAdmin, // ⭐ AJOUTÉ
|
deleteUserAdmin, // ⭐ AJOUTÉ
|
||||||
deleteClientAdmin, // ⭐ AJOUTÉ
|
deleteClientAdmin, // ⭐ AJOUTÉ
|
||||||
|
CreateUser,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import type { ClientResponse } from "../../api/api_admin";
|
import type { ClientResponse } from "../../api/api_admin";
|
||||||
import type { AdminResponse } from "../../api/api_admin_types";
|
import type { AdminResponse } from "../../api/api_admin_types";
|
||||||
import EditUserModal from "../../components/EditUserModal";
|
import EditUserModal from "../../components/EditUserModal";
|
||||||
|
import Toast from "../../components/Toast";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -72,7 +75,22 @@ function AdminUsers() {
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [createUsername, setCreateUsername] = useState("");
|
||||||
|
const [createPassword, setCreatePassword] = useState("");
|
||||||
|
const [createRole, setCreateRole] = useState<
|
||||||
|
"livreur" | "admin" | "cabine"
|
||||||
|
>("admin");
|
||||||
|
const [toast, setToast] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
// Stats
|
// Stats
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -181,7 +199,6 @@ function AdminUsers() {
|
|||||||
|
|
||||||
// Si erreur 401, rediriger vers login
|
// Si erreur 401, rediriger vers login
|
||||||
if (error instanceof Error && error.message.includes("401")) {
|
if (error instanceof Error && error.message.includes("401")) {
|
||||||
console.log("🔓 [AdminUsers] Token invalide - Redirection");
|
|
||||||
sessionStorage.removeItem("admin_token");
|
sessionStorage.removeItem("admin_token");
|
||||||
sessionStorage.removeItem("admin_username");
|
sessionStorage.removeItem("admin_username");
|
||||||
navigate("/login-admin/admin", { replace: true });
|
navigate("/login-admin/admin", { replace: true });
|
||||||
@@ -193,7 +210,102 @@ function AdminUsers() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
setShowCreateModal(true);
|
||||||
|
// Reset form
|
||||||
|
setCreateUsername("");
|
||||||
|
setCreatePassword("");
|
||||||
|
setCreateRole("admin");
|
||||||
|
};
|
||||||
|
const confirmCreateUser = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!createUsername.trim()) {
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: "Le nom d'utilisateur est requis",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!createPassword.trim()) {
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: "Le mot de passe est requis",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createPassword.length < 6) {
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: "Le mot de passe doit contenir au moins 6 caractères",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("➕ [CREATE] Création utilisateur:", {
|
||||||
|
username: createUsername,
|
||||||
|
role: createRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await CreateUser(
|
||||||
|
createUsername.trim(),
|
||||||
|
createPassword,
|
||||||
|
createRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// ✅ TOAST DE SUCCÈS
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: `${createUsername} (${createRole}) créé avec succès !`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fermer la modal
|
||||||
|
setShowCreateModal(false);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setCreateUsername("");
|
||||||
|
setCreatePassword("");
|
||||||
|
setCreateRole("admin");
|
||||||
|
|
||||||
|
// Recharger la liste
|
||||||
|
if (filterRole !== "all") {
|
||||||
|
await fetchUsersByRole(filterRole);
|
||||||
|
} else {
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ✅ TOAST D'ERREUR
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: `Erreur: ${result.error || "Impossible de créer l'utilisateur"}`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ✅ TOAST D'ERREUR
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: `Erreur réseau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelCreate = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreateUsername("");
|
||||||
|
setCreatePassword("");
|
||||||
|
setCreateRole("admin");
|
||||||
|
};
|
||||||
// ✅ NOUVELLE FONCTION: Récupérer les utilisateurs par rôle
|
// ✅ NOUVELLE FONCTION: Récupérer les utilisateurs par rôle
|
||||||
const fetchUsersByRole = async (role: FilterRole) => {
|
const fetchUsersByRole = async (role: FilterRole) => {
|
||||||
// ✅ Vérifier l'auth avant de charger les données
|
// ✅ Vérifier l'auth avant de charger les données
|
||||||
@@ -431,10 +543,9 @@ function AdminUsers() {
|
|||||||
|
|
||||||
const getRoleLabel = (role: string) => {
|
const getRoleLabel = (role: string) => {
|
||||||
const labels: { [key: string]: string } = {
|
const labels: { [key: string]: string } = {
|
||||||
client: "Client",
|
|
||||||
livreur: "Livreur",
|
livreur: "Livreur",
|
||||||
admin: "Administrateur",
|
admin: "Administrateur",
|
||||||
cabine: "Opérateur Cabine",
|
cabine: "Cabine",
|
||||||
};
|
};
|
||||||
return labels[role] || role;
|
return labels[role] || role;
|
||||||
};
|
};
|
||||||
@@ -614,9 +725,25 @@ function AdminUsers() {
|
|||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<div className="admin-container">
|
<div className="admin-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
{toast.show && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={3000}
|
||||||
|
onClose={() => setToast({ ...toast, show: false })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="users-header">
|
<div className="users-header">
|
||||||
<div className="header-content">
|
<div className="header-content">
|
||||||
<h1>Gestion des Utilisateurs</h1>
|
<h1>Gestion des Utilisateurs</h1>
|
||||||
|
{/* ⭐ NOUVEAU BOUTON */}
|
||||||
|
<button
|
||||||
|
className="create-user-btn"
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
>
|
||||||
|
<UserPlus size={20} />
|
||||||
|
<span>Créer un utilisateur</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1420,6 +1547,118 @@ function AdminUsers() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showCreateModal && (
|
||||||
|
<>
|
||||||
|
<div className="modal-overlay" onClick={cancelCreate} />
|
||||||
|
<div className="create-user-modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Créer un nouvel utilisateur</h2>
|
||||||
|
<button
|
||||||
|
className="close-modal"
|
||||||
|
onClick={cancelCreate}
|
||||||
|
>
|
||||||
|
<XCircle size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="create-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="create-username">
|
||||||
|
Nom d'utilisateur *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Entrez le nom d'utilisateur"
|
||||||
|
value={createUsername}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateUsername(
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="create-password">
|
||||||
|
Mot de passe *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Minimum 8 caractères"
|
||||||
|
value={createPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreatePassword(
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="create-role">
|
||||||
|
Rôle *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="create-role"
|
||||||
|
value={createRole}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateRole(
|
||||||
|
e.target
|
||||||
|
.value as typeof createRole,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="livreur">
|
||||||
|
Livreur
|
||||||
|
</option>
|
||||||
|
<option value="cabine">
|
||||||
|
Cabine
|
||||||
|
</option>
|
||||||
|
<option value="admin">
|
||||||
|
Administrateur
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="action-button secondary"
|
||||||
|
onClick={cancelCreate}
|
||||||
|
disabled={createLoading}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-button primary"
|
||||||
|
onClick={confirmCreateUser}
|
||||||
|
disabled={
|
||||||
|
createLoading ||
|
||||||
|
!createUsername.trim() ||
|
||||||
|
!createPassword.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner" />
|
||||||
|
<span>Création...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
<span>Créer l'utilisateur</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal d'édition */}
|
{/* Modal d'édition */}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn-2 {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn-2:hover {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(255, 255, 255, 0.08) 0%,
|
rgba(255, 255, 255, 0.08) 0%,
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn-2.active {
|
||||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -862,7 +862,7 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn-2 {
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,21 +337,21 @@ function CabineAlerts() {
|
|||||||
|
|
||||||
<div className="alerts-filter-buttons">
|
<div className="alerts-filter-buttons">
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("all")}
|
onClick={() => setStatusFilter("all")}
|
||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
Toutes ({alerts.length})
|
Toutes ({alerts.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("true")}
|
onClick={() => setStatusFilter("true")}
|
||||||
>
|
>
|
||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
Actives ({stats.active})
|
Actives ({stats.active})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
|
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
|
||||||
onClick={() => setStatusFilter("false")}
|
onClick={() => setStatusFilter("false")}
|
||||||
>
|
>
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { getProductById, isUserAuthenticated } from '../../api/api';
|
import { getProductById, isUserAuthenticated } from "../../api/api";
|
||||||
import type { Product } from '../../api/api';
|
import type { Product } from "../../api/api";
|
||||||
import { useCart } from '../../context/CartContext';
|
import { useCart } from "../../context/CartContext";
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from "../../components/Navbar";
|
||||||
import './ProductDetail.css';
|
import Toast from "../../components/Toast";
|
||||||
|
import "./ProductDetail.css";
|
||||||
|
|
||||||
function ProductDetail() {
|
function ProductDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -19,12 +20,25 @@ function ProductDetail() {
|
|||||||
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
|
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
|
||||||
const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
|
const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
|
||||||
|
|
||||||
|
// ✅ TOAST STATE
|
||||||
|
const [toast, setToast] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
if (!isUserAuthenticated()) {
|
if (!isUserAuthenticated()) {
|
||||||
console.log('❌ [ProductDetail] Utilisateur non authentifié, redirection vers /login/client');
|
console.log(
|
||||||
navigate('/login/client', { replace: true });
|
"❌ [ProductDetail] Utilisateur non authentifié, redirection vers /login/client",
|
||||||
|
);
|
||||||
|
navigate("/login/client", { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,8 +49,10 @@ function ProductDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authInterval = setInterval(() => {
|
const authInterval = setInterval(() => {
|
||||||
if (!isUserAuthenticated()) {
|
if (!isUserAuthenticated()) {
|
||||||
console.log('❌ [ProductDetail] Session expirée, redirection vers /login/client');
|
console.log(
|
||||||
navigate('/login/client', { replace: true });
|
"❌ [ProductDetail] Session expirée, redirection vers /login/client",
|
||||||
|
);
|
||||||
|
navigate("/login/client", { replace: true });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
@@ -50,8 +66,8 @@ function ProductDetail() {
|
|||||||
const loadProduct = async (productId: number) => {
|
const loadProduct = async (productId: number) => {
|
||||||
// ✅ Vérifier l'auth avant de charger le produit
|
// ✅ Vérifier l'auth avant de charger le produit
|
||||||
if (!isUserAuthenticated()) {
|
if (!isUserAuthenticated()) {
|
||||||
console.log('❌ [loadProduct] Non authentifié');
|
console.log("❌ [loadProduct] Non authentifié");
|
||||||
navigate('/login/client', { replace: true });
|
navigate("/login/client", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +80,13 @@ function ProductDetail() {
|
|||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const fixedProduct = {
|
const fixedProduct = {
|
||||||
...response.data,
|
...response.data,
|
||||||
prices: response.data.prices?.map((p: { quantity: number; price: number }) => ({
|
prices:
|
||||||
|
response.data.prices?.map(
|
||||||
|
(p: { quantity: number; price: number }) => ({
|
||||||
quantity: parseFloat(String(p.quantity)),
|
quantity: parseFloat(String(p.quantity)),
|
||||||
price: parseFloat(String(p.price)),
|
price: parseFloat(String(p.price)),
|
||||||
})) || []
|
}),
|
||||||
|
) || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setProduct(fixedProduct);
|
setProduct(fixedProduct);
|
||||||
@@ -77,12 +96,11 @@ function ProductDetail() {
|
|||||||
setSelectedGrams(fixedProduct.prices[0].quantity);
|
setSelectedGrams(fixedProduct.prices[0].quantity);
|
||||||
setSelectedPrice(fixedProduct.prices[0].price);
|
setSelectedPrice(fixedProduct.prices[0].price);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
setError(response.message || 'Produit non trouvé');
|
setError(response.message || "Produit non trouvé");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erreur lors du chargement du produit');
|
setError(err.message || "Erreur lors du chargement du produit");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -93,7 +111,7 @@ function ProductDetail() {
|
|||||||
setSelectedGrams(floatQty);
|
setSelectedGrams(floatQty);
|
||||||
|
|
||||||
const priceOption = product?.prices?.find(
|
const priceOption = product?.prices?.find(
|
||||||
p => p.quantity === floatQty
|
(p) => p.quantity === floatQty,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (priceOption) {
|
if (priceOption) {
|
||||||
@@ -104,12 +122,20 @@ function ProductDetail() {
|
|||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
// ✅ Vérifier l'auth avant d'ajouter au panier
|
// ✅ Vérifier l'auth avant d'ajouter au panier
|
||||||
if (!isUserAuthenticated()) {
|
if (!isUserAuthenticated()) {
|
||||||
console.log('❌ [handleAddToCart] Non authentifié');
|
console.log("❌ [handleAddToCart] Non authentifié");
|
||||||
navigate('/login/client', { replace: true });
|
navigate("/login/client", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product || isOutOfStock || selectedGrams === null) return;
|
if (!product || isOutOfStock || selectedGrams === null) {
|
||||||
|
// ✅ Toast d'erreur si conditions non remplies
|
||||||
|
setToast({
|
||||||
|
show: true,
|
||||||
|
message: "Veuillez sélectionner une quantité",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
addToCart({
|
addToCart({
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
@@ -118,29 +144,13 @@ function ProductDetail() {
|
|||||||
quantity: selectedGrams,
|
quantity: selectedGrams,
|
||||||
price: selectedPrice,
|
price: selectedPrice,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ FIXED: Gestion correcte du type media (string[] | undefined)
|
// ✅ Toast de succès
|
||||||
const getProductImage = (product: Product): string => {
|
setToast({
|
||||||
if (!product.media || product.media.length === 0) {
|
show: true,
|
||||||
return '/default-product.jpg';
|
message: `${product.name} (${selectedGrams}g) ajouté au panier !`,
|
||||||
}
|
type: "success",
|
||||||
|
});
|
||||||
// ✅ product.media est de type string[] selon l'interface Product
|
|
||||||
const firstMedia = product.media[0];
|
|
||||||
|
|
||||||
// ✅ Vérifier si c'est une string directement ou un objet
|
|
||||||
if (typeof firstMedia === 'string') {
|
|
||||||
return firstMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Si c'est un objet avec une propriété url, l'extraire
|
|
||||||
if (firstMedia && typeof firstMedia === 'object' && 'url' in firstMedia) {
|
|
||||||
const mediaUrl = (firstMedia as any).url;
|
|
||||||
return mediaUrl || '/default-product.jpg';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/default-product.jpg';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -162,8 +172,11 @@ function ProductDetail() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="product-detail-container">
|
<div className="product-detail-container">
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
<h2>{error || 'Produit non trouvé'}</h2>
|
<h2>{error || "Produit non trouvé"}</h2>
|
||||||
<button onClick={() => navigate('/user/accueil')} className="back-button">
|
<button
|
||||||
|
onClick={() => navigate("/user/accueil")}
|
||||||
|
className="back-button"
|
||||||
|
>
|
||||||
Retour aux produits
|
Retour aux produits
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,35 +192,38 @@ function ProductDetail() {
|
|||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
|
{/* ✅ TOAST NOTIFICATION */}
|
||||||
|
{toast.show && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={3000}
|
||||||
|
onClose={() => setToast({ ...toast, show: false })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="product-detail-container">
|
<div className="product-detail-container">
|
||||||
<button onClick={() => navigate(-1)} className="back-button">
|
<button onClick={() => navigate(-1)} className="back-button">
|
||||||
← Retour
|
← Retour
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="product-detail-content">
|
<div className="product-detail-content">
|
||||||
|
|
||||||
<div className={`product-image-section ${isOutOfStock ? 'out-of-stock' : ''}`}>
|
|
||||||
<img
|
|
||||||
src={getProductImage(product)}
|
|
||||||
alt={product.name}
|
|
||||||
className="product-detail-image"
|
|
||||||
/>
|
|
||||||
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="product-info-section">
|
<div className="product-info-section">
|
||||||
|
|
||||||
<h1 className="product-detail-name">{product.name}</h1>
|
<h1 className="product-detail-name">{product.name}</h1>
|
||||||
|
|
||||||
{selectedPrice > 0 && (
|
{selectedPrice > 0 && (
|
||||||
<p className="product-detail-price">
|
<p className="product-detail-price">
|
||||||
{selectedPrice.toFixed(2)} € {selectedGrams && `pour ${selectedGrams}g`}
|
{selectedPrice.toFixed(2)} €{" "}
|
||||||
|
{selectedGrams && `pour ${selectedGrams}g`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="product-description">
|
<div className="product-description">
|
||||||
<h3>Description</h3>
|
<h3>Description</h3>
|
||||||
<p>{product.description || 'Aucune description disponible.'}</p>
|
<p>
|
||||||
|
{product.description ||
|
||||||
|
"Aucune description disponible."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="product-stock-info">
|
<div className="product-stock-info">
|
||||||
@@ -217,16 +233,27 @@ function ProductDetail() {
|
|||||||
|
|
||||||
<select
|
<select
|
||||||
id="grams"
|
id="grams"
|
||||||
value={selectedGrams ?? ''}
|
value={selectedGrams ?? ""}
|
||||||
onChange={(e) => handleGramsChange(parseFloat(e.target.value))}
|
onChange={(e) =>
|
||||||
|
handleGramsChange(
|
||||||
|
parseFloat(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
className="grams-dropdown"
|
className="grams-dropdown"
|
||||||
disabled={isOutOfStock}
|
disabled={isOutOfStock}
|
||||||
>
|
>
|
||||||
<option value="">Choisir une quantité</option>
|
<option value="">
|
||||||
|
Choisir une quantité
|
||||||
|
</option>
|
||||||
|
|
||||||
{product.prices && product.prices.map((p) => (
|
{product.prices &&
|
||||||
<option key={p.quantity} value={p.quantity}>
|
product.prices.map((p) => (
|
||||||
{p.quantity}g - {p.price.toFixed(2)} €
|
<option
|
||||||
|
key={p.quantity}
|
||||||
|
value={p.quantity}
|
||||||
|
>
|
||||||
|
{p.quantity}g -{" "}
|
||||||
|
{p.price.toFixed(2)} €
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -235,13 +262,14 @@ function ProductDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`add-to-cart-button ${(isOutOfStock || selectedGrams === null) ? 'disabled' : ''}`}
|
className={`add-to-cart-button ${isOutOfStock || selectedGrams === null ? "disabled" : ""}`}
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
disabled={isOutOfStock || selectedGrams === null}
|
disabled={isOutOfStock || selectedGrams === null}
|
||||||
>
|
>
|
||||||
{isOutOfStock ? 'Rupture de stock' : 'Ajouter au panier'}
|
{isOutOfStock
|
||||||
|
? "Rupture de stock"
|
||||||
|
: "Ajouter au panier"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user