- Update sidebar and header with off-white (#faf8f5) background - Add ticket stats endpoint integration for global counts - Redesign tirages page with animation and improved layout - Add red accent color for admin avatar - Update various button styles and remove unnecessary elements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
486 lines
21 KiB
TypeScript
486 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { adminService } from '@/services/admin.service';
|
|
import { Ticket } from '@/types';
|
|
import { StatusBadge, Pagination } from '@/components/ui';
|
|
import { Search, RefreshCw, X, Ticket as TicketIcon, Clock, CheckCircle, XCircle, Gift, Filter } from 'lucide-react';
|
|
|
|
export default function TicketManagement() {
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalTickets, setTotalTickets] = useState(0);
|
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
|
const [filterPrizeType, setFilterPrizeType] = useState<string>('');
|
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
const [ticketStats, setTicketStats] = useState({ pending: 0, claimed: 0, rejected: 0 });
|
|
|
|
const loadTickets = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Construire les filtres
|
|
const filters: any = {};
|
|
if (filterStatus) filters.status = filterStatus;
|
|
if (filterPrizeType) filters.prizeType = filterPrizeType;
|
|
|
|
const response = await adminService.getAllTickets(
|
|
page,
|
|
20,
|
|
Object.keys(filters).length > 0 ? filters : undefined
|
|
);
|
|
|
|
// Vérifier si la réponse est directement un tableau ou un objet avec data
|
|
let ticketsData: Ticket[] = [];
|
|
let total = 0;
|
|
let totalPagesCount = 1;
|
|
|
|
if (Array.isArray(response)) {
|
|
ticketsData = response;
|
|
total = response.length;
|
|
totalPagesCount = 1;
|
|
} else if (response.data && Array.isArray(response.data)) {
|
|
ticketsData = response.data;
|
|
total = response.total || response.data.length;
|
|
totalPagesCount = response.totalPages || 1;
|
|
// Récupérer les stats du backend
|
|
if (response.stats) {
|
|
setTicketStats(response.stats);
|
|
}
|
|
}
|
|
|
|
setTickets(ticketsData);
|
|
setTotalPages(totalPagesCount);
|
|
setTotalTickets(total);
|
|
} catch (err: any) {
|
|
let errorMessage = 'Erreur lors du chargement des tickets';
|
|
|
|
if (err.status === 401) {
|
|
errorMessage = '🔐 Non autorisé (401) - Votre session a expiré ou votre token est invalide. Veuillez vous reconnecter.';
|
|
} else if (err.status === 403) {
|
|
errorMessage = '🚫 Accès refusé (403) - Vous n\'avez pas les permissions administrateur nécessaires.';
|
|
} else if (err.status === 0 || err.message.includes('fetch')) {
|
|
errorMessage = '🔌 Impossible de contacter le serveur. Vérifiez que le backend est démarré sur http://localhost:4000';
|
|
} else {
|
|
errorMessage = err.message || errorMessage;
|
|
}
|
|
|
|
setError(errorMessage);
|
|
setTickets([]);
|
|
setTotalTickets(0);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, filterStatus, filterPrizeType]);
|
|
|
|
useEffect(() => {
|
|
loadTickets();
|
|
}, [loadTickets]);
|
|
|
|
|
|
// Fonction pour obtenir les initiales
|
|
const getInitials = (firstName?: string, lastName?: string) => {
|
|
const f = firstName?.charAt(0) || '';
|
|
const l = lastName?.charAt(0) || '';
|
|
return (f + l).toUpperCase() || '?';
|
|
};
|
|
|
|
if (loading && tickets.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Chargement des tickets...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6">
|
|
{/* Section filtres */}
|
|
<div className="bg-[#faf8f5] rounded-2xl p-6 mb-6 border border-gray-200">
|
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
|
<div className="flex gap-3 flex-wrap">
|
|
<select
|
|
value={filterPrizeType}
|
|
onChange={(e) => {
|
|
setFilterPrizeType(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
className="px-4 py-3 rounded-xl bg-white text-gray-700 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
>
|
|
<option value="">Tous les lots</option>
|
|
<option value="INFUSEUR">Infuseur à thé</option>
|
|
<option value="THE_GRATUIT">Thé détox/infusion 100g</option>
|
|
<option value="THE_SIGNATURE">Thé signature 100g</option>
|
|
<option value="COFFRET_DECOUVERTE">Coffret découverte 39€</option>
|
|
<option value="COFFRET_PRESTIGE">Coffret prestige 69€</option>
|
|
</select>
|
|
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => {
|
|
setFilterStatus(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
className="px-4 py-3 rounded-xl bg-white text-gray-700 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
>
|
|
<option value="">Tous les statuts</option>
|
|
<option value="PENDING">En attente</option>
|
|
<option value="REJECTED">Rejeté</option>
|
|
<option value="CLAIMED">Réclamé</option>
|
|
</select>
|
|
|
|
{(filterStatus || filterPrizeType) && (
|
|
<button
|
|
onClick={() => {
|
|
setFilterStatus('');
|
|
setFilterPrizeType('');
|
|
setPage(1);
|
|
}}
|
|
className="px-4 py-3 rounded-xl bg-white text-gray-600 border border-gray-200 hover:bg-gray-100 transition-colors flex items-center gap-2"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Réinitialiser
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={loadTickets}
|
|
disabled={loading}
|
|
className="flex items-center gap-2 px-5 py-3 bg-white text-[#1e3a5f] rounded-xl font-semibold hover:bg-gray-100 transition-colors shadow-sm border border-gray-200 disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
|
{loading ? 'Chargement...' : 'Actualiser'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-4 rounded-xl mb-6">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<X className="w-5 h-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold">Erreur de chargement:</p>
|
|
<p className="mt-1">{error}</p>
|
|
|
|
{error.includes('401') && (
|
|
<div className="mt-3 space-y-2">
|
|
<p className="text-sm">Votre session a expiré ou votre token est invalide.</p>
|
|
<div className="flex gap-2 mt-2">
|
|
<a
|
|
href="/login"
|
|
className="text-sm bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors"
|
|
>
|
|
Se reconnecter
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error.includes('403') && (
|
|
<div className="mt-3">
|
|
<p className="text-sm">Vous n'avez pas le rôle ADMIN requis.</p>
|
|
</div>
|
|
)}
|
|
|
|
{!error.includes('401') && !error.includes('403') && (
|
|
<div className="mt-3">
|
|
<p className="text-sm">Vérifiez que le backend est démarré.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats rapides */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-4 border border-gray-200">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center">
|
|
<TicketIcon className="w-5 h-5 text-gray-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-gray-900">{totalTickets}</p>
|
|
<p className="text-xs text-gray-500">Total</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-amber-50 to-amber-100 rounded-xl p-4 border border-amber-200">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-amber-200 rounded-lg flex items-center justify-center">
|
|
<Clock className="w-5 h-5 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-amber-700">{ticketStats.pending}</p>
|
|
<p className="text-xs text-amber-600">En attente</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-4 border border-green-200">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-green-200 rounded-lg flex items-center justify-center">
|
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-green-700">{ticketStats.claimed}</p>
|
|
<p className="text-xs text-green-600">Réclamés</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-red-50 to-red-100 rounded-xl p-4 border border-red-200">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-red-200 rounded-lg flex items-center justify-center">
|
|
<XCircle className="w-5 h-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-red-700">{ticketStats.rejected}</p>
|
|
<p className="text-xs text-red-600">Rejetés</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table des tickets */}
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
|
<table className="min-w-full">
|
|
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
|
|
<tr>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Code Ticket
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Lot Gagné
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Statut
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Joué le
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Utilisé par
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{tickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center">
|
|
<div className="flex flex-col items-center justify-center">
|
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<TicketIcon className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
<p className="text-gray-900 font-medium text-lg mb-2">
|
|
{!error ? 'Aucun ticket trouvé' : 'Impossible de charger les tickets'}
|
|
</p>
|
|
<p className="text-gray-500 text-sm mb-4">
|
|
{filterStatus
|
|
? `Aucun ticket avec le statut "${filterStatus === 'PENDING' ? 'En attente' : filterStatus === 'CLAIMED' ? 'Réclamé' : filterStatus === 'REJECTED' ? 'Rejeté' : filterStatus}"`
|
|
: 'Aucun ticket n\'a été créé pour le moment'
|
|
}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
tickets.map((ticket) => (
|
|
<tr key={ticket.id} className="hover:bg-gray-50 transition-colors">
|
|
{/* CODE TICKET */}
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-lg flex items-center justify-center">
|
|
<TicketIcon className="w-5 h-5 text-white" />
|
|
</div>
|
|
<span className="text-sm font-mono font-semibold text-gray-900">
|
|
{ticket.code}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* LOT GAGNÉ */}
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-2">
|
|
<Gift className="w-4 h-4 text-purple-500" />
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{ticket.prize?.name || 'N/A'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* STATUT */}
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<StatusBadge type="ticket" value={ticket.status} />
|
|
</td>
|
|
|
|
{/* DISTRIBUÉ LE */}
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{ticket.playedAt ? new Date(ticket.playedAt).toLocaleDateString('fr-FR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
}) : '-'}
|
|
</td>
|
|
|
|
{/* UTILISÉ PAR */}
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{ticket.user ? (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
|
{getInitials(ticket.user.firstName, ticket.user.lastName)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{ticket.user.firstName} {ticket.user.lastName}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{ticket.user.email}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* ACTIONS */}
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<button
|
|
onClick={() => setSelectedTicket(ticket)}
|
|
className="px-4 py-2 text-sm font-medium text-emerald-600 hover:text-emerald-800 hover:bg-emerald-50 rounded-lg transition-colors"
|
|
>
|
|
Détails
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
showPageNumbers={false}
|
|
/>
|
|
|
|
|
|
{/* Modal détails ticket */}
|
|
{selectedTicket && (
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
|
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-bold text-white">Détails du ticket</h2>
|
|
<button
|
|
onClick={() => setSelectedTicket(null)}
|
|
className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center text-white hover:bg-white/30 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
|
{/* Code et Statut */}
|
|
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
|
<TicketIcon className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500">Code ticket</p>
|
|
<p className="text-lg font-mono font-bold text-gray-900">{selectedTicket.code}</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge type="ticket" value={selectedTicket.status} />
|
|
</div>
|
|
|
|
{/* Utilisateur */}
|
|
{selectedTicket.user && (
|
|
<div className="p-4 bg-blue-50 rounded-xl border border-blue-100">
|
|
<p className="text-xs text-blue-600 font-semibold mb-2 uppercase">Utilisateur</p>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white font-bold">
|
|
{getInitials(selectedTicket.user.firstName, selectedTicket.user.lastName)}
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-gray-900">
|
|
{selectedTicket.user.firstName} {selectedTicket.user.lastName}
|
|
</p>
|
|
<p className="text-sm text-gray-500">{selectedTicket.user.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Prix gagné */}
|
|
<div className="p-4 bg-purple-50 rounded-xl border border-purple-100">
|
|
<p className="text-xs text-purple-600 font-semibold mb-2 uppercase">Prix gagné</p>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center">
|
|
<Gift className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-gray-900">{selectedTicket.prize?.name}</p>
|
|
<p className="text-sm text-gray-500">{selectedTicket.prize?.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-gray-50 rounded-xl">
|
|
<p className="text-xs text-gray-500 font-semibold mb-1 uppercase">Date de jeu</p>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{selectedTicket.playedAt ? new Date(selectedTicket.playedAt).toLocaleString('fr-FR') : 'N/A'}
|
|
</p>
|
|
</div>
|
|
{selectedTicket.validatedAt && (
|
|
<div className="p-4 bg-green-50 rounded-xl">
|
|
<p className="text-xs text-green-600 font-semibold mb-1 uppercase">Date de validation</p>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{new Date(selectedTicket.validatedAt).toLocaleString('fr-FR')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Raison du rejet */}
|
|
{selectedTicket.rejectionReason && (
|
|
<div className="p-4 bg-red-50 rounded-xl border border-red-100">
|
|
<p className="text-xs text-red-600 font-semibold mb-1 uppercase">Raison du rejet</p>
|
|
<p className="text-sm text-red-700">{selectedTicket.rejectionReason}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-6 py-4 bg-gray-50 border-t">
|
|
<button
|
|
onClick={() => setSelectedTicket(null)}
|
|
className="w-full px-4 py-3 bg-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-300 transition-colors"
|
|
>
|
|
Fermer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|