the-tip-top-frontend/components/admin/TicketManagement.tsx
soufiane 4d46456ada refactor: reduce code duplication by using reusable components
- Delete duplicate page-new.tsx in verification folder
- Create reusable StatCard component in components/ui
- Enhance StatusBadge component with icons and REJECTED status
- Refactor 7 files to use StatusBadge instead of local getStatusBadge
- Refactor Statistics.tsx to use shared StatCard component
- Reduces overall code duplication from 9.85% to lower

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:50:05 +01:00

410 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { adminService } from '@/services/admin.service';
import { Ticket } from '@/types';
import { StatusBadge } from '@/components/ui';
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 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;
}
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]);
if (loading && tickets.length === 0) {
return <div className="text-center py-8">Chargement des tickets...</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Tickets</h1>
<button
onClick={loadTickets}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Chargement...' : 'Actualiser'}
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
<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 font-medium"> Le header Authorization est bien envoyé</p>
<p className="text-sm"> Mais votre token est invalide ou a expiré</p>
<div className="flex gap-2 mt-2">
<a
href="/login"
className="text-sm bg-red-600 text-white px-3 py-1 rounded hover:bg-red-700"
>
Se reconnecter
</a>
<a
href="/admin/diagnostic"
className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Diagnostic complet
</a>
</div>
</div>
)}
{error.includes('403') && (
<div className="mt-3 space-y-2">
<p className="text-sm">Vous n'avez pas le rôle ADMIN requis.</p>
<a
href="/admin/diagnostic"
className="inline-block text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Voir le diagnostic complet
</a>
</div>
)}
{!error.includes('401') && !error.includes('403') && (
<div className="mt-3 space-y-2">
<p className="text-sm">Vérifiez que le backend est démarré sur http://localhost:4000</p>
<a
href="/admin/diagnostic"
className="inline-block text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Lancer le diagnostic
</a>
</div>
)}
</div>
)}
{/* Info et Filtres */}
<div className="mb-4 flex justify-between items-center">
<div className="text-sm text-gray-600">
{totalTickets > 0 && (
<span>
{totalTickets} ticket{totalTickets > 1 ? 's' : ''} au total
{(filterStatus || filterPrizeType) && ' (filtré)'}
</span>
)}
</div>
<div className="flex gap-3">
{/* Filtre par type de lot */}
<select
value={filterPrizeType}
onChange={(e) => {
setFilterPrizeType(e.target.value);
setPage(1);
}}
className="border rounded px-3 py-2"
>
<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>
{/* Filtre par statut */}
<select
value={filterStatus}
onChange={(e) => {
setFilterStatus(e.target.value);
setPage(1);
}}
className="border rounded px-3 py-2"
>
<option value="">Tous les statuts</option>
<option value="PENDING">En attente</option>
<option value="REJECTED">Rejeté</option>
<option value="CLAIMED">Réclamé</option>
</select>
{/* Bouton pour réinitialiser les filtres */}
{(filterStatus || filterPrizeType) && (
<button
onClick={() => {
setFilterStatus('');
setFilterPrizeType('');
setPage(1);
}}
className="text-sm text-gray-600 hover:text-gray-900 underline"
>
Réinitialiser
</button>
)}
</div>
</div>
{/* Table des tickets */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Code Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Lot Gagné
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joué le
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisé par
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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="text-6xl mb-4">🎫</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>
{!error && (
<p className="text-gray-400 text-xs">
Les tickets apparaîtront ici une fois que des utilisateurs auront joué au jeu
</p>
)}
</div>
</td>
</tr>
) : (
tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
{/* CODE TICKET */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono font-medium text-gray-900">
{ticket.code}
</div>
</td>
{/* LOT GAGNÉ */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{ticket.prize?.name || 'N/A'}
</div>
</td>
{/* STATUT */}
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge type="ticket" value={ticket.status} />
</td>
{/* DISTRIBUÉ LE (date d'utilisation du ticket) */}
<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">
<div className="text-sm text-gray-900">
{ticket.user ? `${ticket.user.firstName} ${ticket.user.lastName}` : '-'}
</div>
{ticket.user && (
<div className="text-xs text-gray-500">
{ticket.user.email}
</div>
)}
</td>
{/* ACTIONS */}
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setSelectedTicket(ticket)}
className="text-blue-600 hover:text-blue-900"
>
Détails
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex justify-center gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
</button>
<span className="px-4 py-2">
Page {page} sur {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
</button>
</div>
{/* Modal détails ticket */}
{selectedTicket && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Détails du ticket</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Code</p>
<p className="mt-1 text-sm font-mono">{selectedTicket.code}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Statut</p>
<p className="mt-1">
<StatusBadge type="ticket" value={selectedTicket.status} />
</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Utilisateur</p>
<p className="mt-1 text-sm">
{selectedTicket.user ? `${selectedTicket.user.firstName} ${selectedTicket.user.lastName}` : 'N/A'}
</p>
<p className="text-sm text-gray-500">{selectedTicket.user?.email}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Prix gagné</p>
<p className="mt-1 text-sm">{selectedTicket.prize?.name}</p>
<p className="text-sm text-gray-500">{selectedTicket.prize?.description}</p>
<p className="text-sm font-medium">{selectedTicket.prize?.value}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Date de jeu</p>
<p className="mt-1 text-sm">
{selectedTicket.playedAt ? new Date(selectedTicket.playedAt).toLocaleString('fr-FR') : 'N/A'}
</p>
</div>
{selectedTicket.validatedAt && (
<div>
<p className="text-sm font-medium text-gray-500">Date de validation</p>
<p className="mt-1 text-sm">
{new Date(selectedTicket.validatedAt).toLocaleString('fr-FR')}
</p>
</div>
)}
</div>
{selectedTicket.rejectionReason && (
<div>
<p className="text-sm font-medium text-gray-500">Raison du rejet</p>
<p className="mt-1 text-sm text-red-600">{selectedTicket.rejectionReason}</p>
</div>
)}
</div>
<div className="mt-6">
<button
onClick={() => setSelectedTicket(null)}
className="w-full bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}