import { useState, useEffect } from 'react' import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin' import { searchTicketApi, deliverClaimApi } from '../services/employee' import { useHandleAuthError } from '../hooks/useHandleAuthError' function StatCard({ label, value, sub }) { return (

{value ?? '—'}

{label}

{sub &&

{sub}

}
) } // Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé » // ne doit pas être confondu avec « retraits uniquement ». const PHASE_BADGES = { NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' }, SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' }, OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' }, CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' }, CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' }, } const PHASE_PANELS = { NOT_CONFIGURED: 'bg-amber-50 border border-amber-200', SCHEDULED: 'bg-indigo-50 border border-indigo-200', OPEN: 'bg-white', CLAIM_ONLY: 'bg-white', CLOSED: 'bg-red-50 border border-red-200', } const PHASE_TITLES = { NOT_CONFIGURED: 'Jeu-concours non lancé', SCHEDULED: 'Jeu-concours programmé', OPEN: 'Jeu-concours en cours', CLAIM_ONLY: 'Participations closes, retraits en cours', CLOSED: 'Jeu-concours terminé', } 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) // Lancement du jeu-concours const [startDateInput, setStartDateInput] = useState('') const [settingsSaving, setSettingsSaving] = useState(false) const [settingsError, setSettingsError] = useState('') const [settingsOk, setSettingsOk] = useState('') // 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 handleSaveGameSettings = async (e) => { e.preventDefault() setSettingsError('') setSettingsOk('') setSettingsSaving(true) try { // L'input datetime-local renvoie une heure locale sans fuseau : on la // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté. const data = await setGameSettingsApi(new Date(startDateInput).toISOString()) setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev)) setSettingsOk(data.message) setStartDateInput('') } catch (err) { if (!handleAuthError(err)) setSettingsError(err.message) } finally { setSettingsSaving(false) } } 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') // Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le // CSV en ANSI et casse tous les accents. Ne pas le supprimer. // eslint-disable-next-line no-irregular-whitespace const blob = new Blob([`${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 (

Chargement...

) if (error) return (

{error}

) const winnerUser = winner?.winner?.user return (

Tableau de bord Admin

{/* Fenêtre du jeu-concours */} {stats?.gameWindow && (

{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}

{stats.gameWindow.configured ? (

Début : {formatDate(stats.gameWindow.startDate)}
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}

) : (

Choisissez une date de début pour ouvrir le jeu-concours.

)}
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
setStartDateInput(e.target.value)} required className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />

Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.

{settingsError &&

{settingsError}

} {settingsOk &&

{settingsOk}

}
)} {/* Stats principales */}
{/* Stats tickets */}
{/* Stats démographiques */} {stats?.genderStats && stats?.ageStats && (

Genre des participants

{[ { label: 'Femmes', value: stats.genderStats.women }, { label: 'Hommes', value: stats.genderStats.men }, { label: 'Autre / non renseigné', value: stats.genderStats.other }, ].map((row) => (
{row.label} {row.value}
))}

Âge des participants

{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`} {stats.ageStats[bracket] ?? 0}
))}
)} {/* Répartition par lot */} {stats?.lotsStats?.length > 0 && (

Répartition des lots

{stats.lotsStats.map((lot) => ( ))}
Lot % Tickets Participations
{lot.name} {lot.percentage}% {lot._count.tickets.toLocaleString('fr-FR')} {lot._count.participations}
)} {/* ── Contacts emailing ── */}

Contacts emailing

{emailingContacts?.length > 0 && ( )}
{emailingError &&

{emailingError}

}

Clients ayant accepté d'être contactés à des fins marketing. {emailingContacts && <> — {emailingContacts.length} contact(s)}

{emailingContacts && (
{emailingContacts.length === 0 && ( )} {emailingContacts.map((contact) => ( ))}
Nom Email Inscrit le
Aucun contact n'a accepté l'emailing pour le moment
{contact.firstname} {contact.lastname} {contact.email} {new Date(contact.createdAt).toLocaleDateString('fr-FR')}
)}
{/* ── Remise de lot ── */}

Remise de lot

setTicketCode(e.target.value.toUpperCase())} placeholder="Code ticket (10 caractères)" maxLength={10} className="flex-1 min-w-0 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]" />
{ticketSearchErr && (

{ticketSearchErr}

)} {ticketResult && (
{ticketSuccess && (

Lot remis avec succès.

)}

{ticketResult.lot.name}

{ticketResult.lot.description}

{ticketResult.lot.value &&

{ticketResult.lot.value} €

}
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis' : ticketResult.claim?.status === 'CANCELLED' ? 'Expiré' : 'À remettre'}
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}

{ticketResult.client.firstname} {ticketResult.client.lastname}

{ticketResult.client.email}

{ticketDeliverErr &&

{ticketDeliverErr}

} {ticketResult.claim?.status === 'PENDING' && !ticketSuccess && ( )}
)}
{/* ── Tirage au sort ── */}

Grand tirage au sort

{winnerUser ? ( /* Gagnant déjà tiré */
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}

Gagnant du grand tirage

{winnerUser.firstname} {winnerUser.lastname}

{winnerUser.email}

{winner?.winner?.createdAt && (

Tirage effectué le {formatDate(winner.winner.createdAt)}

)} {/* Signalé uniquement au retour du tirage : le GET du gagnant ne renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */} {winner?.emailSent === false && (

L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.

)}
) : ( /* Pas encore de tirage */

{stats?.participations ?? 0} participations enregistrées

{stats?.users ?? 0} participants éligibles

{drawError && (

{drawError}

)}
)}
{/* Liste participants */}

Participants ({users.length})

{users.length === 0 && ( )} {users.map((user) => ( ))}
Nom Email Participations
Aucun participant pour le moment
{user.firstname} {user.lastname} {user.email} {user._count.participations}
{/* ── Modal de confirmation du tirage ── */} {showConfirm && (

Lancer le tirage au sort ?

Un gagnant sera sélectionné aléatoirement parmi les {stats?.users} participants.

Cette action est irréversible. Le tirage ne peut être effectué qu'une seule fois.

)}
) }