merge: integrate Emmanuel changes into main
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
@@ -39,6 +40,13 @@ const PHASE_TITLES = {
|
||||
CLOSED: 'Jeu-concours terminé',
|
||||
}
|
||||
|
||||
// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau.
|
||||
function toDateTimeLocalValue(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
@@ -63,24 +71,25 @@ export default function Admin() {
|
||||
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('')
|
||||
|
||||
// Création d'un compte employé
|
||||
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' })
|
||||
const [employeeSaving, setEmployeeSaving] = useState(false)
|
||||
const [employeeError, setEmployeeError] = useState('')
|
||||
const [employeeOk, setEmployeeOk] = useState('')
|
||||
// Incrémenté à chaque création pour recharger la liste des employés.
|
||||
const [employeesVersion, setEmployeesVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||
.then(([s, u, w]) => {
|
||||
setStats(s)
|
||||
// Le champ part de la date enregistrée, celle affichée sur l'accueil.
|
||||
if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate))
|
||||
setUsers(u.users)
|
||||
setWinner(w)
|
||||
})
|
||||
@@ -100,8 +109,8 @@ export default function Admin() {
|
||||
// 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('')
|
||||
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
|
||||
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||
} finally {
|
||||
@@ -145,10 +154,7 @@ export default function Admin() {
|
||||
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 blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -157,43 +163,20 @@ export default function Admin() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleTicketSearch = async (e) => {
|
||||
const handleCreateEmployee = async (e) => {
|
||||
e.preventDefault()
|
||||
setTicketResult(null)
|
||||
setTicketSearchErr('')
|
||||
setTicketDeliverErr('')
|
||||
setTicketSuccess(false)
|
||||
setTicketSearching(true)
|
||||
setEmployeeError('')
|
||||
setEmployeeOk('')
|
||||
setEmployeeSaving(true)
|
||||
try {
|
||||
const data = await searchTicketApi(ticketCode.trim())
|
||||
setTicketResult(data)
|
||||
const data = await createEmployeeApi(employeeForm)
|
||||
setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`)
|
||||
setEmployeeForm({ firstname: '', lastname: '', email: '' })
|
||||
setEmployeesVersion((v) => v + 1)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
||||
if (!handleAuthError(err)) setEmployeeError(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)
|
||||
setEmployeeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +194,15 @@ export default function Admin() {
|
||||
|
||||
const winnerUser = winner?.winner?.user
|
||||
|
||||
// Aperçu en direct des dates publiques pour la date saisie.
|
||||
const startInputDate = startDateInput ? new Date(startDateInput) : null
|
||||
const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime())
|
||||
? gameDatesFromStart(startInputDate)
|
||||
: null
|
||||
const savedStart = stats?.gameWindow?.startDate
|
||||
const startPreviewUnsaved = Boolean(startPreview) &&
|
||||
(!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
@@ -267,6 +259,17 @@ export default function Admin() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{startPreview && (
|
||||
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
|
||||
Affiché sur la page d'accueil :{' '}
|
||||
<span className="font-semibold">
|
||||
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
|
||||
</span>
|
||||
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
|
||||
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||
</p>
|
||||
@@ -418,95 +421,66 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Remise de lot ── */}
|
||||
{/* ── Création d'un compte employé ── */}
|
||||
<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>
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
||||
L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.
|
||||
</p>
|
||||
|
||||
<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 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]"
|
||||
/>
|
||||
<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-500 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>
|
||||
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-firstname"
|
||||
value={employeeForm.firstname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-lastname"
|
||||
value={employeeForm.lastname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="employee-email"
|
||||
value={employeeForm.email}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={employeeSaving}
|
||||
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"
|
||||
>
|
||||
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
|
||||
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Employés et remises des lots ── */}
|
||||
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
Reference in New Issue
Block a user