All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10m9s
706 lines
23 KiB
TypeScript
706 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import {
|
|
validateChallenge,
|
|
rejectChallenge,
|
|
updateChallenge,
|
|
deleteChallenge,
|
|
adminCancelChallenge,
|
|
reactivateChallenge,
|
|
adminAcceptChallenge,
|
|
} from "@/actions/admin/challenges";
|
|
import {
|
|
Button,
|
|
Card,
|
|
Input,
|
|
Textarea,
|
|
Alert,
|
|
Modal,
|
|
CloseButton,
|
|
} from "@/components/ui";
|
|
import { Avatar } from "@/components/ui";
|
|
|
|
interface Challenge {
|
|
id: string;
|
|
challenger: {
|
|
id: string;
|
|
username: string;
|
|
avatar: string | null;
|
|
};
|
|
challenged: {
|
|
id: string;
|
|
username: string;
|
|
avatar: string | null;
|
|
};
|
|
title: string;
|
|
description: string;
|
|
pointsReward: number;
|
|
status: string;
|
|
adminComment: string | null;
|
|
createdAt: string;
|
|
acceptedAt: string | null;
|
|
}
|
|
|
|
export default function ChallengeManagement() {
|
|
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(
|
|
null
|
|
);
|
|
const [editingChallenge, setEditingChallenge] = useState<Challenge | null>(
|
|
null
|
|
);
|
|
const [winnerId, setWinnerId] = useState<string>("");
|
|
const [adminComment, setAdminComment] = useState("");
|
|
const [editTitle, setEditTitle] = useState("");
|
|
const [editDescription, setEditDescription] = useState("");
|
|
const [editPointsReward, setEditPointsReward] = useState<number>(0);
|
|
const [isPending, startTransition] = useTransition();
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchChallenges();
|
|
}, []);
|
|
|
|
const fetchChallenges = async () => {
|
|
try {
|
|
const response = await fetch("/api/admin/challenges");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setChallenges(data);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching challenges:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
if (!selectedChallenge || !winnerId) {
|
|
setErrorMessage("Veuillez sélectionner un gagnant");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await validateChallenge(
|
|
selectedChallenge.id,
|
|
winnerId,
|
|
adminComment || undefined
|
|
);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage(
|
|
"Défi validé avec succès ! Les points ont été attribués."
|
|
);
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
fetchChallenges();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la validation");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!selectedChallenge) return;
|
|
|
|
if (!confirm("Êtes-vous sûr de vouloir rejeter ce défi ?")) {
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await rejectChallenge(
|
|
selectedChallenge.id,
|
|
adminComment || undefined
|
|
);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi rejeté");
|
|
setSelectedChallenge(null);
|
|
setAdminComment("");
|
|
fetchChallenges();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors du rejet");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleEdit = (challenge: Challenge) => {
|
|
setEditingChallenge(challenge);
|
|
setEditTitle(challenge.title);
|
|
setEditDescription(challenge.description);
|
|
setEditPointsReward(challenge.pointsReward);
|
|
};
|
|
|
|
const handleUpdate = async () => {
|
|
if (!editingChallenge) return;
|
|
|
|
startTransition(async () => {
|
|
const result = await updateChallenge(editingChallenge.id, {
|
|
title: editTitle,
|
|
description: editDescription,
|
|
pointsReward: editPointsReward,
|
|
});
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi mis à jour avec succès");
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
fetchChallenges();
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la mise à jour");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleDelete = async (challengeId: string) => {
|
|
if (
|
|
!confirm(
|
|
"Êtes-vous sûr de vouloir supprimer ce défi ? Cette action est irréversible."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await deleteChallenge(challengeId);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi supprimé avec succès");
|
|
fetchChallenges();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la suppression");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
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();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
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();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la réactivation");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleAdminAccept = async (challengeId: string) => {
|
|
if (
|
|
!confirm(
|
|
"Êtes-vous sûr de vouloir accepter ce défi à la place de l'utilisateur ?"
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await adminAcceptChallenge(challengeId);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi accepté avec succès");
|
|
fetchChallenges();
|
|
// Rafraîchir le badge des défis
|
|
window.dispatchEvent(new Event("refreshChallenges"));
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de l'acceptation");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-center text-pixel-gold py-8">Chargement...</div>
|
|
);
|
|
}
|
|
|
|
if (challenges.length === 0) {
|
|
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">
|
|
{successMessage && (
|
|
<Alert variant="success" className="mb-4">
|
|
{successMessage}
|
|
</Alert>
|
|
)}
|
|
{errorMessage && (
|
|
<Alert variant="error" className="mb-4">
|
|
{errorMessage}
|
|
</Alert>
|
|
)}
|
|
<div className="text-sm text-gray-400 mb-4">
|
|
{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={acceptedChallenges.length > 0 ? "ml-2" : ""}>
|
|
• {pendingChallenges.length} défi
|
|
{pendingChallenges.length > 1 ? "s" : ""} en attente
|
|
d'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) => (
|
|
<Card key={challenge.id} variant="dark" className="p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-bold text-pixel-gold mb-2">
|
|
{challenge.title}
|
|
</h3>
|
|
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
|
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
src={challenge.challenger.avatar}
|
|
username={challenge.challenger.username}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
{challenge.challenger.username}
|
|
</span>
|
|
<span className="text-xs text-gray-500">VS</span>
|
|
<Avatar
|
|
src={challenge.challenged.avatar}
|
|
username={challenge.challenged.username}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
{challenge.challenged.username}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-400">
|
|
Récompense:{" "}
|
|
<span className="text-pixel-gold font-bold">
|
|
{challenge.pointsReward} points
|
|
</span>
|
|
</div>
|
|
<div className="text-xs mt-2">
|
|
<span
|
|
className={`px-2 py-1 rounded ${
|
|
challenge.status === "ACCEPTED"
|
|
? "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 === "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 && (
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
Accepté le:{" "}
|
|
{new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Button
|
|
onClick={() => handleEdit(challenge)}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
Modifier
|
|
</Button>
|
|
{challenge.status === "PENDING" && (
|
|
<Button
|
|
onClick={() => handleAdminAccept(challenge.id)}
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={isPending}
|
|
>
|
|
Accepter le défi
|
|
</Button>
|
|
)}
|
|
{challenge.status === "ACCEPTED" && (
|
|
<Button
|
|
onClick={() => setSelectedChallenge(challenge)}
|
|
variant="primary"
|
|
size="sm"
|
|
>
|
|
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
|
|
onClick={() => handleDelete(challenge.id)}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
style={{
|
|
color: "var(--destructive)",
|
|
borderColor: "var(--destructive)",
|
|
}}
|
|
>
|
|
Supprimer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
|
|
{/* Modal de validation */}
|
|
{selectedChallenge && (
|
|
<Modal
|
|
isOpen={!!selectedChallenge}
|
|
onClose={() => {
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
}}
|
|
size="lg"
|
|
>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold text-pixel-gold">
|
|
Désigner le gagnant
|
|
</h2>
|
|
<CloseButton
|
|
onClick={() => {
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
}}
|
|
size="lg"
|
|
/>
|
|
</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="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="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>
|
|
)}
|
|
|
|
{/* Modal d'édition */}
|
|
{editingChallenge && (
|
|
<Modal
|
|
isOpen={!!editingChallenge}
|
|
onClose={() => {
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
}}
|
|
size="lg"
|
|
>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold text-pixel-gold">
|
|
Modifier le défi
|
|
</h2>
|
|
<CloseButton
|
|
onClick={() => {
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
}}
|
|
size="lg"
|
|
/>
|
|
</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"
|
|
/>
|
|
|
|
<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)
|
|
}
|
|
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>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|