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:
@@ -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 {
|
||||
@@ -441,115 +449,115 @@ export default function ChallengeManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-300 mb-2">
|
||||
{selectedChallenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{selectedChallenge.description}
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-300 mb-2">
|
||||
{selectedChallenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{selectedChallenge.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenger.avatar}
|
||||
username={selectedChallenge.challenger.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenged.avatar}
|
||||
username={selectedChallenge.challenged.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenger.avatar}
|
||||
username={selectedChallenge.challenger.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenged.avatar}
|
||||
username={selectedChallenge.challenged.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Sélectionner le gagnant
|
||||
</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="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Sélectionner le gagnant
|
||||
</label>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -582,67 +590,67 @@ export default function ChallengeManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="edit-title"
|
||||
label="Titre"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
placeholder="Titre du défi"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="edit-title"
|
||||
label="Titre"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
placeholder="Titre du défi"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
label="Description"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Description du défi"
|
||||
/>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
label="Description"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Description du défi"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="edit-points"
|
||||
label="Récompense (points)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={editPointsReward}
|
||||
onChange={(e) =>
|
||||
setEditPointsReward(parseInt(e.target.value) || 0)
|
||||
<Input
|
||||
id="edit-points"
|
||||
label="Récompense (points)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={editPointsReward}
|
||||
onChange={(e) =>
|
||||
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
|
||||
placeholder="100"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
variant="primary"
|
||||
disabled={
|
||||
isPending ||
|
||||
!editTitle ||
|
||||
!editDescription ||
|
||||
editPointsReward <= 0
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Mise à jour..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Mise à jour..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -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 à "{viewingRegistrations.name}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user