561 lines
26 KiB
JavaScript
561 lines
26 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
|
|
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
|
|
|
function StatCard({ label, value, sub }) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow p-4 text-center">
|
|
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
|
|
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</p>
|
|
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
day: '2-digit', month: 'long', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
export default function Admin() {
|
|
const handleAuthError = useHandleAuthError()
|
|
const [stats, setStats] = useState(null)
|
|
const [users, setUsers] = useState([])
|
|
const [winner, setWinner] = useState(undefined)
|
|
const [drawing, setDrawing] = useState(false)
|
|
const [drawError, setDrawError] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
|
|
// Remise de lot
|
|
const [ticketCode, setTicketCode] = useState('')
|
|
const [ticketResult, setTicketResult] = useState(null)
|
|
const [ticketSearching, setTicketSearching] = useState(false)
|
|
const [ticketDelivering, setTicketDelivering] = useState(false)
|
|
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
|
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
|
const [ticketSuccess, setTicketSuccess] = useState(false)
|
|
|
|
// Contacts emailing
|
|
const [emailingContacts, setEmailingContacts] = useState(null)
|
|
const [emailingLoading, setEmailingLoading] = useState(false)
|
|
const [emailingError, setEmailingError] = useState('')
|
|
|
|
useEffect(() => {
|
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
|
.then(([s, u, w]) => {
|
|
setStats(s)
|
|
setUsers(u.users)
|
|
setWinner(w)
|
|
})
|
|
.catch((e) => {
|
|
if (!handleAuthError(e)) setError(e.message)
|
|
})
|
|
.finally(() => setLoading(false))
|
|
}, [handleAuthError])
|
|
|
|
const handleDraw = async () => {
|
|
setShowConfirm(false)
|
|
setDrawing(true)
|
|
setDrawError('')
|
|
try {
|
|
const data = await drawWinnerApi()
|
|
setWinner(data)
|
|
} catch (e) {
|
|
if (!handleAuthError(e)) setDrawError(e.message)
|
|
} finally {
|
|
setDrawing(false)
|
|
}
|
|
}
|
|
|
|
const handleLoadEmailingContacts = async () => {
|
|
setEmailingLoading(true)
|
|
setEmailingError('')
|
|
try {
|
|
const data = await getEmailingContactsApi()
|
|
setEmailingContacts(data.contacts)
|
|
} catch (e) {
|
|
if (!handleAuthError(e)) setEmailingError(e.message)
|
|
} finally {
|
|
setEmailingLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleExportEmailingCsv = () => {
|
|
if (!emailingContacts?.length) return
|
|
const header = ['Prénom', 'Nom', 'Email', 'Inscrit le']
|
|
const rows = emailingContacts.map((c) => [
|
|
c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'),
|
|
])
|
|
const csv = [header, ...rows]
|
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
|
.join('\n')
|
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv`
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const handleTicketSearch = async (e) => {
|
|
e.preventDefault()
|
|
setTicketResult(null)
|
|
setTicketSearchErr('')
|
|
setTicketDeliverErr('')
|
|
setTicketSuccess(false)
|
|
setTicketSearching(true)
|
|
try {
|
|
const data = await searchTicketApi(ticketCode.trim())
|
|
setTicketResult(data)
|
|
} catch (err) {
|
|
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
|
} finally {
|
|
setTicketSearching(false)
|
|
}
|
|
}
|
|
|
|
const handleTicketDeliver = async () => {
|
|
if (!ticketResult?.claim?.id) return
|
|
setTicketDeliverErr('')
|
|
setTicketDelivering(true)
|
|
try {
|
|
await deliverClaimApi(ticketResult.claim.id)
|
|
setTicketResult((prev) => ({
|
|
...prev,
|
|
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
|
}))
|
|
setTicketSuccess(true)
|
|
setStats((prev) => prev ? {
|
|
...prev,
|
|
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
|
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
|
} : prev)
|
|
} catch (err) {
|
|
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
|
} finally {
|
|
setTicketDelivering(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<p className="text-gray-400">Chargement...</p>
|
|
</div>
|
|
)
|
|
|
|
if (error) return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
|
<p className="text-red-500 text-sm">{error}</p>
|
|
</div>
|
|
)
|
|
|
|
const winnerUser = winner?.winner?.user
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
|
<div className="max-w-5xl mx-auto space-y-6">
|
|
|
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
|
|
|
{/* Fenêtre du jeu-concours */}
|
|
{stats?.gameWindow?.configured && (
|
|
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-800">
|
|
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
|
|
</p>
|
|
</div>
|
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
|
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats principales */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
|
<StatCard label="Participants" value={stats?.users} />
|
|
<StatCard label="Participations" value={stats?.participations} />
|
|
<StatCard label="Gains en attente" value={stats?.claimsPending} />
|
|
<StatCard label="Gains remis" value={stats?.claimsDelivered} />
|
|
</div>
|
|
|
|
{/* Stats tickets */}
|
|
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
|
<StatCard label="Tickets total" value={stats?.ticketsTotal} />
|
|
<StatCard label="Tickets utilisés" value={stats?.ticketsUsed} />
|
|
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
|
</div>
|
|
|
|
{/* Stats démographiques */}
|
|
{stats?.genderStats && stats?.ageStats && (
|
|
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Genre des participants</h2>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ label: 'Femmes', value: stats.genderStats.women },
|
|
{ label: 'Hommes', value: stats.genderStats.men },
|
|
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
|
|
].map((row) => (
|
|
<div key={row.label} className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-500">{row.label}</span>
|
|
<span className="font-semibold text-gray-800">{row.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
|
|
<div className="space-y-2">
|
|
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
|
|
<div key={bracket} className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
|
|
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Répartition par lot */}
|
|
{stats?.lotsStats?.length > 0 && (
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Répartition des lots</h2>
|
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
|
<table className="w-full text-sm min-w-[480px]">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
|
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
|
<th className="pb-2 px-4 sm:px-0 text-center">%</th>
|
|
<th className="pb-2 px-4 sm:px-0 text-center">Tickets</th>
|
|
<th className="pb-2 px-4 sm:px-0 text-center">Participations</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stats.lotsStats.map((lot) => (
|
|
<tr key={lot.id} className="border-b last:border-0">
|
|
<td className="py-2.5 px-4 sm:px-0 font-medium text-gray-800 text-xs sm:text-sm">{lot.name}</td>
|
|
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-500 text-xs sm:text-sm">{lot.percentage}%</td>
|
|
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-600 text-xs sm:text-sm">{lot._count.tickets.toLocaleString('fr-FR')}</td>
|
|
<td className="py-2.5 px-4 sm:px-0 text-center text-[#006B6B] font-semibold text-xs sm:text-sm">{lot._count.participations}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Contacts emailing ── */}
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleLoadEmailingContacts}
|
|
disabled={emailingLoading}
|
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
|
>
|
|
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
|
|
</button>
|
|
{emailingContacts?.length > 0 && (
|
|
<button
|
|
onClick={handleExportEmailingCsv}
|
|
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
|
>
|
|
Exporter en CSV
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
|
|
|
|
<p className="text-sm text-gray-500 mb-3">
|
|
Clients ayant accepté d'être contactés à des fins marketing.
|
|
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
|
|
</p>
|
|
|
|
{emailingContacts && (
|
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
|
<table className="w-full text-sm min-w-[400px]">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
|
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{emailingContacts.length === 0 && (
|
|
<tr>
|
|
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
|
|
Aucun contact n'a accepté l'emailing pour le moment
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{emailingContacts.map((contact) => (
|
|
<tr key={contact.email} className="border-b last:border-0">
|
|
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
|
{contact.firstname} {contact.lastname}
|
|
</td>
|
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
|
|
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
|
|
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Remise de lot ── */}
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
|
|
|
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
|
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
|
<input
|
|
type="text"
|
|
id="admin-ticket-code"
|
|
value={ticketCode}
|
|
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
|
placeholder="Code ticket (10 caractères)"
|
|
maxLength={10}
|
|
className="flex-1 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={ticketSearching || ticketCode.trim().length === 0}
|
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
|
>
|
|
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
|
</button>
|
|
</form>
|
|
|
|
{ticketSearchErr && (
|
|
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
|
)}
|
|
|
|
{ticketResult && (
|
|
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
|
{ticketSuccess && (
|
|
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
|
<div>
|
|
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
|
<p className="text-gray-400 text-xs mt-0.5">{ticketResult.lot.description}</p>
|
|
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
|
</div>
|
|
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
|
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
|
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
|
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
|
: 'À remettre'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
|
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
|
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
|
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-4 py-3 space-y-2">
|
|
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
|
|
|
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
|
<button
|
|
onClick={handleTicketDeliver}
|
|
disabled={ticketDelivering}
|
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
|
>
|
|
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
|
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
|
>
|
|
Rechercher un autre code
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Tirage au sort ── */}
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
|
|
|
{winnerUser ? (
|
|
/* Gagnant déjà tiré */
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-4 p-4 bg-[#006B6B]/5 rounded-xl border border-[#006B6B]/20">
|
|
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold flex-shrink-0">
|
|
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs text-[#006B6B] font-semibold uppercase tracking-wider mb-0.5">Gagnant du grand tirage</p>
|
|
<p className="font-bold text-gray-900 text-base">{winnerUser.firstname} {winnerUser.lastname}</p>
|
|
<p className="text-gray-500 text-sm truncate">{winnerUser.email}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{winner?.winner?.createdAt && (
|
|
<p className="text-xs text-gray-400 text-right">
|
|
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Pas encore de tirage */
|
|
<div className="space-y-3">
|
|
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
|
<p className="text-sm text-gray-600 mb-1">
|
|
<span className="font-semibold text-gray-800">{stats?.participations ?? 0}</span> participations enregistrées
|
|
</p>
|
|
<p className="text-sm text-gray-600">
|
|
<span className="font-semibold text-gray-800">{stats?.users ?? 0}</span> participants éligibles
|
|
</p>
|
|
</div>
|
|
|
|
{drawError && (
|
|
<p className="text-red-500 text-sm px-1">{drawError}</p>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setShowConfirm(true)}
|
|
disabled={drawing || (stats?.users ?? 0) === 0}
|
|
className="flex items-center gap-2 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-xl transition-colors text-sm"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
|
</svg>
|
|
{drawing ? 'Tirage en cours…' : 'Lancer le tirage au sort'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Liste participants */}
|
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
|
|
Participants <span className="text-gray-400 font-normal text-sm">({users.length})</span>
|
|
</h2>
|
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
|
<table className="w-full text-sm min-w-[400px]">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
|
<th className="pb-2 px-4 sm:px-0 text-right">Participations</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.length === 0 && (
|
|
<tr>
|
|
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
|
|
Aucun participant pour le moment
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{users.map((user) => (
|
|
<tr key={user.id} className="border-b last:border-0">
|
|
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
|
{user.firstname} {user.lastname}
|
|
</td>
|
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{user.email}</td>
|
|
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
|
|
{user._count.participations}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* ── Modal de confirmation du tirage ── */}
|
|
{showConfirm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6">
|
|
|
|
<div className="flex justify-center mb-4">
|
|
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center">
|
|
<svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Lancer le tirage au sort ?</h3>
|
|
<p className="text-sm text-gray-500 text-center mb-4">
|
|
Un gagnant sera sélectionné aléatoirement parmi les <strong>{stats?.users}</strong> participants.
|
|
</p>
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-5 flex gap-2 items-start">
|
|
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
</svg>
|
|
<p className="text-xs text-red-600">Cette action est <strong>irréversible</strong>. Le tirage ne peut être effectué qu'une seule fois.</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowConfirm(false)}
|
|
className="flex-1 border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleDraw}
|
|
className="flex-1 bg-[#006B6B] hover:bg-[#005959] text-white font-semibold py-2.5 rounded-xl transition-colors text-sm"
|
|
>
|
|
Confirmer le tirage
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|