- Add reCAPTCHA v2 to registration form - Add reset-password page for password recovery - Fix forgot-password to call real API - Sort employee pending tickets (most recent first) - Update contest dates (validation: Dec 1-31, recovery: Dec 1 - Jan 31) - Update draw date to Feb 1, 2026 - Improve GamePeriod and GrandPrize components design 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { employeeService } from "@/services/employee.service";
|
|
import { Ticket } from "@/types";
|
|
import toast from "react-hot-toast";
|
|
import { StatusBadge, StatCard } from "@/components/ui";
|
|
import {
|
|
Search,
|
|
CheckCircle,
|
|
XCircle,
|
|
RefreshCw,
|
|
Users,
|
|
Clock,
|
|
BarChart3,
|
|
} from "lucide-react";
|
|
|
|
export default function EmployeeVerificationPage() {
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchCode, setSearchCode] = useState("");
|
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [validating, setValidating] = useState(false);
|
|
const [rejectReason, setRejectReason] = useState("");
|
|
const [showRejectInput, setShowRejectInput] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadPendingTickets();
|
|
}, []);
|
|
|
|
const loadPendingTickets = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await employeeService.getPendingTickets();
|
|
// Trier par date décroissante (plus récent en premier)
|
|
const sortedData = data.sort((a: Ticket, b: Ticket) => {
|
|
const dateA = a.playedAt ? new Date(a.playedAt).getTime() : 0;
|
|
const dateB = b.playedAt ? new Date(b.playedAt).getTime() : 0;
|
|
return dateB - dateA;
|
|
});
|
|
setTickets(sortedData);
|
|
} catch (error: any) {
|
|
console.error("Error loading tickets:", error);
|
|
toast.error("Erreur lors du chargement des tickets");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
if (!searchCode.trim()) {
|
|
toast.error("Veuillez entrer un code de ticket");
|
|
return;
|
|
}
|
|
|
|
const ticket = tickets.find(
|
|
(t) => t.code.toLowerCase() === searchCode.toLowerCase()
|
|
);
|
|
|
|
if (ticket) {
|
|
setSelectedTicket(ticket);
|
|
setShowModal(true);
|
|
setSearchCode("");
|
|
} else {
|
|
toast.error("Ticket non trouvé ou déjà traité");
|
|
}
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
if (!selectedTicket) return;
|
|
|
|
setValidating(true);
|
|
try {
|
|
await employeeService.validateTicket(selectedTicket.id, "APPROVE");
|
|
toast.success("✅ Ticket validé ! Le lot peut être remis au client.");
|
|
setShowModal(false);
|
|
setSelectedTicket(null);
|
|
loadPendingTickets();
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Erreur lors de la validation");
|
|
} finally {
|
|
setValidating(false);
|
|
}
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!selectedTicket) return;
|
|
|
|
if (!rejectReason.trim()) {
|
|
toast.error("Veuillez indiquer la raison du rejet");
|
|
return;
|
|
}
|
|
|
|
setValidating(true);
|
|
try {
|
|
await employeeService.validateTicket(
|
|
selectedTicket.id,
|
|
"REJECT",
|
|
rejectReason
|
|
);
|
|
toast.success("Ticket rejeté");
|
|
setShowModal(false);
|
|
setSelectedTicket(null);
|
|
setShowRejectInput(false);
|
|
setRejectReason("");
|
|
loadPendingTickets();
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Erreur lors du rejet");
|
|
} finally {
|
|
setValidating(false);
|
|
}
|
|
};
|
|
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-8">
|
|
<div className="animate-pulse space-y-4">
|
|
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
|
<div className="h-32 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 bg-gray-50 min-h-screen">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
|
Validation des Tickets
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
Scannez ou recherchez un code pour valider les lots gagnés
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search Section */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
Rechercher un ticket
|
|
</h2>
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="Entrez le code du ticket (ex: ABC123)"
|
|
value={searchCode}
|
|
onChange={(e) => setSearchCode(e.target.value.toUpperCase())}
|
|
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono text-lg"
|
|
maxLength={10}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
|
>
|
|
<Search className="w-5 h-5" />
|
|
Rechercher
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lots en attente de remise */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
Lots en attente de remise
|
|
</h2>
|
|
<button
|
|
onClick={loadPendingTickets}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
{tickets.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<div className="text-6xl mb-4">✨</div>
|
|
<p className="text-gray-600 mb-2 font-medium">
|
|
Aucun lot en attente
|
|
</p>
|
|
<p className="text-sm text-gray-500">
|
|
Tous les lots ont été remis !
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<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">
|
|
Client
|
|
</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">
|
|
Date
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Action
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{tickets.map((ticket) => (
|
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{ticket.user ? `${ticket.user.firstName} ${ticket.user.lastName}` : 'N/A'}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{ticket.user?.email || 'N/A'}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{ticket.prize?.name || 'N/A'}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
Code: {ticket.code}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{ticket.playedAt
|
|
? new Date(ticket.playedAt).toLocaleDateString('fr-FR')
|
|
: 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedTicket(ticket);
|
|
setShowModal(true);
|
|
}}
|
|
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
|
>
|
|
Traiter →
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal de validation */}
|
|
{showModal && selectedTicket && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
|
Détails du Ticket
|
|
</h2>
|
|
|
|
<div className="space-y-6">
|
|
{/* Ticket Code */}
|
|
<div className="bg-gray-50 rounded-lg p-6">
|
|
<p className="text-sm font-medium text-gray-600 mb-2">
|
|
Code du ticket
|
|
</p>
|
|
<p className="text-xl font-mono font-bold text-gray-900">
|
|
{selectedTicket.code}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Client Info */}
|
|
<div className="bg-blue-50 rounded-lg p-6 border-l-4 border-blue-500">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-blue-600" />
|
|
Informations du Client
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">Nom complet</p>
|
|
<p className="text-base font-semibold text-gray-900">
|
|
{selectedTicket.user ? `${selectedTicket.user.firstName} ${selectedTicket.user.lastName}` : 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">Email</p>
|
|
<p className="text-sm text-gray-900">
|
|
{selectedTicket.user?.email || 'N/A'}
|
|
</p>
|
|
</div>
|
|
{selectedTicket.user?.phone && (
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">Téléphone</p>
|
|
<p className="text-sm text-gray-900">
|
|
{selectedTicket.user.phone}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Prize Info */}
|
|
<div className="bg-green-50 rounded-lg p-6 border-l-4 border-green-500">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
Lot Gagné
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-xl font-bold text-green-600">
|
|
{selectedTicket.prize?.name || 'N/A'}
|
|
</p>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{selectedTicket.prize?.description || 'Description non disponible'}
|
|
</p>
|
|
</div>
|
|
{selectedTicket.prize?.value && (
|
|
<div className="mt-2">
|
|
<p className="text-sm font-medium text-gray-600">Valeur</p>
|
|
<p className="text-base font-semibold text-gray-900">
|
|
{selectedTicket.prize.value}€
|
|
</p>
|
|
</div>
|
|
)}
|
|
{selectedTicket.playedAt && (
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">Date de gain</p>
|
|
<p className="text-sm text-gray-900">
|
|
{new Date(selectedTicket.playedAt).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reject Reason Input */}
|
|
{showRejectInput && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Raison du rejet *
|
|
</label>
|
|
<textarea
|
|
value={rejectReason}
|
|
onChange={(e) => setRejectReason(e.target.value)}
|
|
placeholder="Ex: Ticket endommagé, code illisible..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 justify-end pt-4">
|
|
{!showRejectInput ? (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setSelectedTicket(null);
|
|
}}
|
|
disabled={validating}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={() => setShowRejectInput(true)}
|
|
disabled={validating}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2"
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
Rejeter
|
|
</button>
|
|
<button
|
|
onClick={handleValidate}
|
|
disabled={validating}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium flex items-center gap-2"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
{validating ? "Validation..." : "Valider et Remettre"}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setShowRejectInput(false);
|
|
setRejectReason("");
|
|
}}
|
|
disabled={validating}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
|
|
>
|
|
Retour
|
|
</button>
|
|
<button
|
|
onClick={handleReject}
|
|
disabled={validating || !rejectReason.trim()}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
{validating ? "Rejet..." : "Confirmer le Rejet"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|