Enhance ChallengeManagement and EventManagement components: Refactor layout for better readability, implement event registration viewing with score editing functionality, and improve user feedback handling in modals. Update EventRegistrationService to fetch event registrations with user details, ensuring a more interactive admin experience.

This commit is contained in:
Julien Froidefond
2025-12-16 16:52:50 +01:00
parent 79c21955e0
commit ec965cd59d
4 changed files with 449 additions and 164 deletions

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id: eventId } = await params;
const registrations = await eventRegistrationService.getEventRegistrations(
eventId
);
return NextResponse.json(registrations);
} catch (error) {
console.error("Error fetching event registrations:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des inscrits" },
{ status: 500 }
);
}
}

View File

@@ -9,7 +9,15 @@ import {
adminCancelChallenge, adminCancelChallenge,
reactivateChallenge, reactivateChallenge,
} from "@/actions/admin/challenges"; } from "@/actions/admin/challenges";
import { Button, Card, Input, Textarea, Alert, Modal, CloseButton } from "@/components/ui"; import {
Button,
Card,
Input,
Textarea,
Alert,
Modal,
CloseButton,
} from "@/components/ui";
import { Avatar } from "@/components/ui"; import { Avatar } from "@/components/ui";
interface Challenge { interface Challenge {
@@ -441,115 +449,115 @@ export default function ChallengeManagement() {
/> />
</div> </div>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-gray-300 mb-2"> <h3 className="text-lg font-bold text-gray-300 mb-2">
{selectedChallenge.title} {selectedChallenge.title}
</h3> </h3>
<p className="text-gray-400 mb-4"> <p className="text-gray-400 mb-4">
{selectedChallenge.description} {selectedChallenge.description}
</p> </p>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar <Avatar
src={selectedChallenge.challenger.avatar} src={selectedChallenge.challenger.avatar}
username={selectedChallenge.challenger.username} username={selectedChallenge.challenger.username}
size="md" size="md"
/> />
<span className="text-gray-300"> <span className="text-gray-300">
{selectedChallenge.challenger.username} {selectedChallenge.challenger.username}
</span> </span>
</div> </div>
<span className="text-gray-500">VS</span> <span className="text-gray-500">VS</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar <Avatar
src={selectedChallenge.challenged.avatar} src={selectedChallenge.challenged.avatar}
username={selectedChallenge.challenged.username} username={selectedChallenge.challenged.username}
size="md" size="md"
/> />
<span className="text-gray-300"> <span className="text-gray-300">
{selectedChallenge.challenged.username} {selectedChallenge.challenged.username}
</span> </span>
</div>
</div> </div>
</div> </div>
</div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2"> <label className="block text-sm font-bold text-pixel-gold mb-2">
Sélectionner le gagnant Sélectionner le gagnant
</label> </label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenger.id}
checked={winnerId === selectedChallenge.challenger.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenger.username}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenged.id}
checked={winnerId === selectedChallenge.challenged.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenged.username}
</span>
</label>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2">
Commentaire (optionnel)
</label>
<textarea
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
rows={3}
placeholder="Commentaire pour les joueurs..."
/>
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <label className="flex items-center gap-2 cursor-pointer">
onClick={handleValidate} <input
variant="primary" type="radio"
disabled={!winnerId || isPending} name="winner"
className="flex-1" value={selectedChallenge.challenger.id}
> checked={winnerId === selectedChallenge.challenger.id}
{isPending ? "Enregistrement..." : "Confirmer le gagnant"} onChange={(e) => setWinnerId(e.target.value)}
</Button> className="w-4 h-4"
<Button />
onClick={handleReject} <span className="text-gray-300">
variant="secondary" {selectedChallenge.challenger.username}
disabled={isPending} </span>
className="flex-1" </label>
> <label className="flex items-center gap-2 cursor-pointer">
{isPending ? "Rejet..." : "Rejeter le défi"} <input
</Button> type="radio"
<Button name="winner"
onClick={() => { value={selectedChallenge.challenged.id}
setSelectedChallenge(null); checked={winnerId === selectedChallenge.challenged.id}
setWinnerId(""); onChange={(e) => setWinnerId(e.target.value)}
setAdminComment(""); className="w-4 h-4"
}} />
variant="secondary" <span className="text-gray-300">
disabled={isPending} {selectedChallenge.challenged.username}
> </span>
Annuler </label>
</Button>
</div> </div>
</div>
<div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2">
Commentaire (optionnel)
</label>
<textarea
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
rows={3}
placeholder="Commentaire pour les joueurs..."
/>
</div>
<div className="flex gap-4">
<Button
onClick={handleValidate}
variant="primary"
disabled={!winnerId || isPending}
className="flex-1"
>
{isPending ? "Enregistrement..." : "Confirmer le gagnant"}
</Button>
<Button
onClick={handleReject}
variant="secondary"
disabled={isPending}
className="flex-1"
>
{isPending ? "Rejet..." : "Rejeter le défi"}
</Button>
<Button
onClick={() => {
setSelectedChallenge(null);
setWinnerId("");
setAdminComment("");
}}
variant="secondary"
disabled={isPending}
>
Annuler
</Button>
</div>
</div> </div>
</Modal> </Modal>
)} )}
@@ -582,67 +590,67 @@ export default function ChallengeManagement() {
/> />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
id="edit-title" id="edit-title"
label="Titre" label="Titre"
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
required required
placeholder="Titre du défi" placeholder="Titre du défi"
/> />
<Textarea <Textarea
id="edit-description" id="edit-description"
label="Description" label="Description"
value={editDescription} value={editDescription}
onChange={(e) => setEditDescription(e.target.value)} onChange={(e) => setEditDescription(e.target.value)}
required required
rows={4} rows={4}
placeholder="Description du défi" placeholder="Description du défi"
/> />
<Input <Input
id="edit-points" id="edit-points"
label="Récompense (points)" label="Récompense (points)"
type="number" type="number"
min="1" min="1"
value={editPointsReward} value={editPointsReward}
onChange={(e) => onChange={(e) =>
setEditPointsReward(parseInt(e.target.value) || 0) setEditPointsReward(parseInt(e.target.value) || 0)
}
required
placeholder="100"
/>
<div className="flex gap-4 pt-4">
<Button
onClick={handleUpdate}
variant="primary"
disabled={
isPending ||
!editTitle ||
!editDescription ||
editPointsReward <= 0
} }
required className="flex-1"
placeholder="100" >
/> {isPending ? "Mise à jour..." : "Enregistrer"}
</Button>
<div className="flex gap-4 pt-4"> <Button
<Button onClick={() => {
onClick={handleUpdate} setEditingChallenge(null);
variant="primary" setEditTitle("");
disabled={ setEditDescription("");
isPending || setEditPointsReward(0);
!editTitle || }}
!editDescription || variant="secondary"
editPointsReward <= 0 disabled={isPending}
} >
className="flex-1" Annuler
> </Button>
{isPending ? "Mise à jour..." : "Enregistrer"}
</Button>
<Button
onClick={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
variant="secondary"
disabled={isPending}
>
Annuler
</Button>
</div>
</div> </div>
</div>
</div> </div>
</Modal> </Modal>
)} )}

View File

@@ -11,7 +11,9 @@ import {
Badge, Badge,
Modal, Modal,
CloseButton, CloseButton,
Avatar,
} from "@/components/ui"; } from "@/components/ui";
import { updateUser } from "@/actions/admin/users";
interface Event { interface Event {
id: string; id: string;
@@ -28,6 +30,24 @@ interface Event {
registrationsCount?: number; registrationsCount?: number;
} }
interface EventRegistration {
id: string;
userId: string;
eventId: string;
createdAt: string;
user: {
id: string;
username: string;
avatar: string | null;
score: number;
level: number;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
};
}
interface EventFormData { interface EventFormData {
date: string; date: string;
name: string; name: string;
@@ -78,6 +98,14 @@ export default function EventManagement() {
const [editingEvent, setEditingEvent] = useState<Event | null>(null); const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [viewingRegistrations, setViewingRegistrations] =
useState<Event | null>(null);
const [registrations, setRegistrations] = useState<EventRegistration[]>([]);
const [loadingRegistrations, setLoadingRegistrations] = useState(false);
const [editingScores, setEditingScores] = useState<Record<string, number>>(
{}
);
const [savingScore, setSavingScore] = useState<string | null>(null);
const [formData, setFormData] = useState<EventFormData>({ const [formData, setFormData] = useState<EventFormData>({
date: "", date: "",
name: "", name: "",
@@ -207,6 +235,75 @@ export default function EventManagement() {
}); });
}; };
const handleViewRegistrations = async (event: Event) => {
setViewingRegistrations(event);
setLoadingRegistrations(true);
try {
const response = await fetch(
`/api/admin/events/${event.id}/registrations`
);
if (response.ok) {
const data = await response.json();
setRegistrations(data);
// Initialiser les scores d'édition avec les scores actuels
const scoresMap: Record<string, number> = {};
data.forEach((reg: EventRegistration) => {
scoresMap[reg.user.id] = reg.user.score;
});
setEditingScores(scoresMap);
} else {
alert("Erreur lors de la récupération des inscrits");
}
} catch (error) {
console.error("Error fetching registrations:", error);
alert("Erreur lors de la récupération des inscrits");
} finally {
setLoadingRegistrations(false);
}
};
const handleCloseRegistrations = () => {
setViewingRegistrations(null);
setRegistrations([]);
setEditingScores({});
};
const handleScoreChange = (userId: string, newScore: number) => {
setEditingScores({
...editingScores,
[userId]: newScore,
});
};
const handleSaveScore = async (userId: string) => {
const newScore = editingScores[userId];
if (newScore === undefined) return;
setSavingScore(userId);
startTransition(async () => {
try {
const result = await updateUser(userId, { score: newScore });
if (result.success) {
// Mettre à jour le score dans la liste locale
setRegistrations((prev) =>
prev.map((reg) =>
reg.user.id === userId
? { ...reg, user: { ...reg.user, score: newScore } }
: reg
)
);
} else {
alert(result.error || "Erreur lors de la mise à jour du score");
}
} catch (error) {
console.error("Error updating score:", error);
alert("Erreur lors de la mise à jour du score");
} finally {
setSavingScore(null);
}
});
};
if (loading) { if (loading) {
return <div className="text-center text-gray-400 py-8">Chargement...</div>; return <div className="text-center text-gray-400 py-8">Chargement...</div>;
} }
@@ -410,7 +507,15 @@ export default function EventManagement() {
</div> </div>
</div> </div>
{!isCreating && !editingEvent && ( {!isCreating && !editingEvent && (
<div className="flex gap-2 sm:ml-4 flex-shrink-0"> <div className="flex gap-2 sm:ml-4 flex-shrink-0 flex-wrap">
<Button
onClick={() => handleViewRegistrations(event)}
variant="primary"
size="sm"
className="whitespace-nowrap"
>
Inscrits ({event.registrationsCount || 0})
</Button>
<Button <Button
onClick={() => handleEdit(event)} onClick={() => handleEdit(event)}
variant="primary" variant="primary"
@@ -435,6 +540,116 @@ export default function EventManagement() {
})} })}
</div> </div>
)} )}
{/* Modal des inscrits */}
{viewingRegistrations && (
<Modal
isOpen={!!viewingRegistrations}
onClose={handleCloseRegistrations}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Inscrits à &quot;{viewingRegistrations.name}&quot;
</h4>
<CloseButton onClick={handleCloseRegistrations} size="lg" />
</div>
{loadingRegistrations ? (
<div className="text-center text-gray-400 py-8">
Chargement...
</div>
) : registrations.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucun inscrit pour cet événement
</div>
) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{registrations.map((registration) => {
const user = registration.user;
const currentScore = editingScores[user.id] ?? user.score;
const isSaving = savingScore === user.id;
return (
<Card
key={registration.id}
variant="default"
className="p-3 sm:p-4"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
src={user.avatar}
username={user.username}
size="md"
borderClassName="border-2 border-pixel-gold/50"
/>
<div className="flex-1 min-w-0">
<h5 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
{user.username}
</h5>
<p className="text-gray-400 text-xs sm:text-sm">
Niveau {user.level} HP: {user.hp}/{user.maxHp}
XP: {user.xp}/{user.maxXp}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 flex-shrink-0">
<div className="flex items-center gap-2">
<label className="text-xs sm:text-sm text-gray-300 whitespace-nowrap">
Score:
</label>
<input
type="number"
value={currentScore}
onChange={(e) =>
handleScoreChange(
user.id,
parseInt(e.target.value) || 0
)
}
disabled={isSaving}
className="w-24 px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center disabled:opacity-50"
/>
</div>
<div className="flex gap-1 sm:gap-2">
<button
onClick={() =>
handleScoreChange(user.id, currentScore - 100)
}
disabled={isSaving}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0 disabled:opacity-50"
>
-100
</button>
<button
onClick={() =>
handleScoreChange(user.id, currentScore + 100)
}
disabled={isSaving}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0 disabled:opacity-50"
>
+100
</button>
<button
onClick={() => handleSaveScore(user.id)}
disabled={isSaving || currentScore === user.score}
className="px-2 sm:px-3 py-1 border border-pixel-gold/50 bg-pixel-gold/20 text-pixel-gold text-[10px] sm:text-xs rounded hover:bg-pixel-gold/30 transition flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? "..." : "Sauver"}
</button>
</div>
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
</Modal>
)}
</div> </div>
); );
} }

View File

@@ -38,7 +38,8 @@ export class EventRegistrationService {
} }
// Récupérer les points à retirer depuis les préférences du site // Récupérer les points à retirer depuis les préférences du site
const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences(); const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
const pointsToRemove = sitePreferences.eventRegistrationPoints || 100; const pointsToRemove = sitePreferences.eventRegistrationPoints || 100;
// Supprimer l'inscription et retirer les points en parallèle // Supprimer l'inscription et retirer les points en parallèle
@@ -118,6 +119,35 @@ export class EventRegistrationService {
return count; return count;
} }
/**
* Récupère tous les inscrits d'un événement avec leurs informations
*/
async getEventRegistrations(eventId: string) {
return prisma.eventRegistration.findMany({
where: {
eventId,
},
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
},
},
},
orderBy: {
createdAt: "asc",
},
});
}
/** /**
* Valide et inscrit un utilisateur à un événement avec toutes les règles métier * Valide et inscrit un utilisateur à un événement avec toutes les règles métier
*/ */
@@ -146,7 +176,8 @@ export class EventRegistrationService {
} }
// Récupérer les points à attribuer depuis les préférences du site // Récupérer les points à attribuer depuis les préférences du site
const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences(); const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
const pointsToAward = sitePreferences.eventRegistrationPoints || 100; const pointsToAward = sitePreferences.eventRegistrationPoints || 100;
// Créer l'inscription et attribuer les points en parallèle // Créer l'inscription et attribuer les points en parallèle