Add admin challenge management features: Implement functions for canceling and reactivating challenges, enhance error handling, and update the ChallengeManagement component to support these actions. Update API to retrieve all challenge statuses for admin and improve UI to display active challenges count.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m16s

This commit is contained in:
Julien Froidefond
2025-12-15 22:19:58 +01:00
parent 633245c1f1
commit bfaf30ee26
9 changed files with 390 additions and 53 deletions

View File

@@ -6,6 +6,8 @@ import {
rejectChallenge,
updateChallenge,
deleteChallenge,
adminCancelChallenge,
reactivateChallenge,
} from "@/actions/admin/challenges";
import { Button, Card, Input, Textarea, Alert } from "@/components/ui";
import { Avatar } from "@/components/ui";
@@ -178,6 +180,44 @@ export default function ChallengeManagement() {
});
};
const handleCancel = async (challengeId: string) => {
if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
return;
}
startTransition(async () => {
const result = await adminCancelChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi annulé avec succès");
fetchChallenges();
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(result.error || "Erreur lors de l'annulation");
setTimeout(() => setErrorMessage(null), 5000);
}
});
};
const handleReactivate = async (challengeId: string) => {
if (!confirm("Êtes-vous sûr de vouloir réactiver ce défi ?")) {
return;
}
startTransition(async () => {
const result = await reactivateChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi réactivé avec succès");
fetchChallenges();
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(result.error || "Erreur lors de la réactivation");
setTimeout(() => setErrorMessage(null), 5000);
}
});
};
if (loading) {
return (
<div className="text-center text-pixel-gold py-8">Chargement...</div>
@@ -185,15 +225,18 @@ export default function ChallengeManagement() {
}
if (challenges.length === 0) {
return (
<div className="text-center text-gray-400 py-8">
Aucun défi en attente
</div>
);
return <div className="text-center text-gray-400 py-8">Aucun défi</div>;
}
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
const cancelledChallenges = challenges.filter(
(c) => c.status === "CANCELLED"
);
const completedChallenges = challenges.filter(
(c) => c.status === "COMPLETED"
);
const rejectedChallenges = challenges.filter((c) => c.status === "REJECTED");
return (
<div className="space-y-4">
@@ -208,15 +251,41 @@ export default function ChallengeManagement() {
</Alert>
)}
<div className="text-sm text-gray-400 mb-4">
{acceptedChallenges.length} défi
{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation
{acceptedChallenges.length > 0 && (
<span>
{acceptedChallenges.length} défi
{acceptedChallenges.length > 1 ? "s" : ""} en attente de désignation
du gagnant
</span>
)}
{pendingChallenges.length > 0 && (
<span className="ml-2">
<span className={acceptedChallenges.length > 0 ? "ml-2" : ""}>
{pendingChallenges.length} défi
{pendingChallenges.length > 1 ? "s" : ""} en attente
d&apos;acceptation
</span>
)}
{cancelledChallenges.length > 0 && (
<span className="ml-2">
{cancelledChallenges.length} défi
{cancelledChallenges.length > 1 ? "s" : ""} annulé
{cancelledChallenges.length > 1 ? "s" : ""}
</span>
)}
{completedChallenges.length > 0 && (
<span className="ml-2">
{completedChallenges.length} défi
{completedChallenges.length > 1 ? "s" : ""} complété
{completedChallenges.length > 1 ? "s" : ""}
</span>
)}
{rejectedChallenges.length > 0 && (
<span className="ml-2">
{rejectedChallenges.length} défi
{rejectedChallenges.length > 1 ? "s" : ""} rejeté
{rejectedChallenges.length > 1 ? "s" : ""}
</span>
)}
</div>
{challenges.map((challenge) => (
@@ -260,13 +329,27 @@ export default function ChallengeManagement() {
<span
className={`px-2 py-1 rounded ${
challenge.status === "ACCEPTED"
? "bg-green-500/20 text-green-400"
: "bg-yellow-500/20 text-yellow-400"
? "bg-blue-500/20 text-blue-400"
: challenge.status === "COMPLETED"
? "bg-green-500/20 text-green-400"
: challenge.status === "CANCELLED"
? "bg-gray-500/20 text-gray-400"
: challenge.status === "REJECTED"
? "bg-red-500/20 text-red-400"
: "bg-yellow-500/20 text-yellow-400"
}`}
>
{challenge.status === "ACCEPTED"
? "Accepté"
: "En attente d'acceptation"}
{challenge.status === "PENDING"
? "En attente d'acceptation"
: challenge.status === "ACCEPTED"
? "En cours - En attente de désignation du gagnant"
: challenge.status === "COMPLETED"
? "Complété"
: challenge.status === "CANCELLED"
? "Annulé"
: challenge.status === "REJECTED"
? "Rejeté"
: challenge.status}
</span>
</div>
{challenge.acceptedAt && (
@@ -291,7 +374,28 @@ export default function ChallengeManagement() {
variant="primary"
size="sm"
>
Valider/Rejeter
Désigner le gagnant
</Button>
)}
{challenge.status !== "CANCELLED" &&
challenge.status !== "COMPLETED" && (
<Button
onClick={() => handleCancel(challenge.id)}
variant="secondary"
size="sm"
disabled={isPending}
>
Annuler
</Button>
)}
{challenge.status === "CANCELLED" && (
<Button
onClick={() => handleReactivate(challenge.id)}
variant="primary"
size="sm"
disabled={isPending}
>
Réactiver
</Button>
)}
<Button
@@ -328,7 +432,7 @@ export default function ChallengeManagement() {
>
<div className="p-6">
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
Valider/Rejeter le défi
Désigner le gagnant
</h2>
<div className="mb-6">
@@ -418,7 +522,7 @@ export default function ChallengeManagement() {
disabled={!winnerId || isPending}
className="flex-1"
>
{isPending ? "Validation..." : "Valider le défi"}
{isPending ? "Enregistrement..." : "Confirmer le gagnant"}
</Button>
<Button
onClick={handleReject}

View File

@@ -140,7 +140,7 @@ export default function ChallengesSection({
const result = await acceptChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi accepté ! En attente de validation admin.");
setSuccessMessage("Défi accepté ! En attente de désignation du gagnant.");
fetchChallenges();
setTimeout(() => setSuccessMessage(null), 5000);
} else {
@@ -174,7 +174,7 @@ export default function ChallengesSection({
case "PENDING":
return "En attente d'acceptation";
case "ACCEPTED":
return "Accepté - En attente de validation admin";
return "En cours - En attente de désignation du gagnant";
case "COMPLETED":
return "Complété";
case "REJECTED":

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ChallengeBadgeProps {
initialCount?: number;
onNavigate?: () => void;
}
export default function ChallengeBadge({
initialCount = 0,
onNavigate,
}: ChallengeBadgeProps) {
const [count, setCount] = useState(initialCount);
useEffect(() => {
// Récupérer le nombre de défis actifs
const fetchActiveCount = async () => {
try {
const response = await fetch("/api/challenges/active-count");
const data = await response.json();
setCount(data.count || 0);
} catch (error) {
console.error("Error fetching active challenges count:", error);
}
};
fetchActiveCount();
// Rafraîchir toutes les 30 secondes
const interval = setInterval(fetchActiveCount, 30000);
return () => clearInterval(interval);
}, []);
return (
<Link
href="/challenges"
onClick={onNavigate}
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
onNavigate ? "py-2" : ""
}`}
style={{ color: "var(--foreground)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--foreground)")
}
title={
count > 0
? `${count} défi${count > 1 ? "s" : ""} actif${count > 1 ? "s" : ""}`
: "Défis"
}
>
<span>DÉFIS</span>
{count > 0 && (
<span
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
style={{
backgroundColor: "var(--accent)",
color: "var(--background)",
}}
>
{count > 9 ? "9+" : count}
</span>
)}
</Link>
);
}

View File

@@ -6,6 +6,7 @@ import { useState } from "react";
import { usePathname } from "next/navigation";
import PlayerStats from "@/components/profile/PlayerStats";
import { Button, ThemeToggle } from "@/components/ui";
import ChallengeBadge from "./ChallengeBadge";
interface UserData {
username: string;
@@ -20,11 +21,13 @@ interface UserData {
interface NavigationProps {
initialUserData?: UserData | null;
initialIsAdmin?: boolean;
initialActiveChallengesCount?: number;
}
export default function Navigation({
initialUserData,
initialIsAdmin,
initialActiveChallengesCount = 0,
}: NavigationProps) {
const { data: session } = useSession();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -114,19 +117,7 @@ export default function Navigation({
LEADERBOARD
</Link>
{isAuthenticated && (
<Link
href="/challenges"
className="transition text-xs font-normal uppercase tracking-widest"
style={{ color: "var(--foreground)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--foreground)")
}
>
DÉFIS
</Link>
<ChallengeBadge initialCount={initialActiveChallengesCount} />
)}
{isAdmin && (
<Link
@@ -287,20 +278,10 @@ export default function Navigation({
LEADERBOARD
</Link>
{isAuthenticated && (
<Link
href="/challenges"
onClick={() => setIsMenuOpen(false)}
className="transition text-xs font-normal uppercase tracking-widest py-2"
style={{ color: "var(--foreground)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--foreground)")
}
>
DÉFIS
</Link>
<ChallengeBadge
initialCount={initialActiveChallengesCount}
onNavigate={() => setIsMenuOpen(false)}
/>
)}
{isAdmin && (
<Link

View File

@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { challengeService } from "@/services/challenges/challenge.service";
import Navigation from "./Navigation";
interface UserData {
@@ -17,6 +18,7 @@ export default async function NavigationWrapper() {
let userData: UserData | null = null;
const isAdmin = session?.user?.role === "ADMIN";
let activeChallengesCount = 0;
if (session?.user?.id) {
const user = await userService.getUserById(session.user.id, {
@@ -32,7 +34,17 @@ export default async function NavigationWrapper() {
if (user) {
userData = user;
}
// Récupérer le nombre de défis actifs
activeChallengesCount =
await challengeService.getActiveChallengesCount(session.user.id);
}
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />;
return (
<Navigation
initialUserData={userData}
initialIsAdmin={isAdmin}
initialActiveChallengesCount={activeChallengesCount}
/>
);
}