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,
reactivateChallenge,
} 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";
interface Challenge {

View File

@@ -11,7 +11,9 @@ import {
Badge,
Modal,
CloseButton,
Avatar,
} from "@/components/ui";
import { updateUser } from "@/actions/admin/users";
interface Event {
id: string;
@@ -28,6 +30,24 @@ interface Event {
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 {
date: string;
name: string;
@@ -78,6 +98,14 @@ export default function EventManagement() {
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [isCreating, setIsCreating] = 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>({
date: "",
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) {
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
}
@@ -410,7 +507,15 @@ export default function EventManagement() {
</div>
</div>
{!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
onClick={() => handleEdit(event)}
variant="primary"
@@ -435,6 +540,116 @@ export default function EventManagement() {
})}
</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>
);
}

View File

@@ -38,7 +38,8 @@ export class EventRegistrationService {
}
// 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;
// Supprimer l'inscription et retirer les points en parallèle
@@ -118,6 +119,35 @@ export class EventRegistrationService {
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
*/
@@ -146,7 +176,8 @@ export class EventRegistrationService {
}
// 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;
// Créer l'inscription et attribuer les points en parallèle