diff --git a/backend/src/controllers/tent.controller.ts b/backend/src/controllers/tent.controller.ts index bec7557..9c68870 100644 --- a/backend/src/controllers/tent.controller.ts +++ b/backend/src/controllers/tent.controller.ts @@ -89,8 +89,10 @@ export const toggleTentConfirmation: AppRequestHandler = async ( const emailOptions = { from: email_from, to: [user1.email, user2.email], - subject: confirmed ? '🎉 Votre tente a Ă©tĂ© validĂ©e !' : 'â›ș Votre tente a Ă©tĂ© dĂ©validĂ©e', - text: '', // optionnel + subject: confirmed + ? "🎉 Votre tente a Ă©tĂ© validĂ©e !" + : "â›ș Votre tente a Ă©tĂ© invalidĂ©e", + text: "", // optionnel html: htmlEmail, }; @@ -98,7 +100,9 @@ export const toggleTentConfirmation: AppRequestHandler = async ( await sendEmail(emailOptions); Ok(res, { - msg: confirmed ? 'Tente validĂ©e et email envoyĂ©.' : 'Tente dĂ©validĂ©e et email envoyĂ©.', + msg: confirmed + ? "Tente validĂ©e et email envoyĂ©." + : "Tente invalidĂ©e et email envoyĂ©.", }); } catch (err: any) { console.error(err); diff --git a/backend/src/services/challenge.service.ts b/backend/src/services/challenge.service.ts index 44d7ece..759d874 100644 --- a/backend/src/services/challenge.service.ts +++ b/backend/src/services/challenge.service.ts @@ -82,7 +82,29 @@ export const validateChallenge = async ({ throw new Error("Type de challenge non valide"); } - // 3. CrĂ©er l'objet de validation du challenge + // 3. VĂ©rifier si ce challenge a dĂ©jĂ  Ă©tĂ© validĂ© pour cette cible + const targetColumn = + type === "user" + ? challengeValidationSchema.target_user_id + : type === "team" + ? challengeValidationSchema.target_team_id + : challengeValidationSchema.target_faction_id; + + const existingValidation = await db + .select() + .from(challengeValidationSchema) + .where( + and( + eq(challengeValidationSchema.challenge_id, challengeId), + eq(targetColumn, targetId) + ) + ); + + if (existingValidation.length > 0) { + throw new Error("Ce challenge a dĂ©jĂ  Ă©tĂ© validĂ© pour cette cible"); + } + + // 4. CrĂ©er l'objet de validation du challenge const newChallengeValidationPoints = { challenge_id: challengeId, validated_by_admin_id: validatedBy, @@ -94,7 +116,7 @@ export const validateChallenge = async ({ target_faction_id: target_faction_id, }; - // 4. InsĂ©rer la validation du challenge dans la base de donnĂ©es + // 5. InsĂ©rer la validation du challenge dans la base de donnĂ©es const inserted = await db.insert(challengeValidationSchema).values(newChallengeValidationPoints).returning(); return inserted[0]; diff --git a/backend/src/services/tent.service.ts b/backend/src/services/tent.service.ts index ed9b32b..5f02fce 100644 --- a/backend/src/services/tent.service.ts +++ b/backend/src/services/tent.service.ts @@ -131,5 +131,5 @@ export const toggleTentConfirmation = async ( ) ); - return { success: true, message: confirmed ? "Tente validĂ©e." : "Tente dĂ©validĂ©e." }; + return { success: true, message: confirmed ? "Tente validĂ©e." : "Tente invalidĂ©e." }; }; diff --git a/frontend/src/components/Admin/AdminChallenge/adminChalengeList.tsx b/frontend/src/components/Admin/AdminChallenge/adminChalengeList.tsx index 0fbb322..850369c 100644 --- a/frontend/src/components/Admin/AdminChallenge/adminChalengeList.tsx +++ b/frontend/src/components/Admin/AdminChallenge/adminChalengeList.tsx @@ -1,16 +1,16 @@ -import { CheckCircle2, Edit, Search, Trash2 } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import Select, { type SingleValue } from 'react-select'; -import Swal from 'sweetalert2'; - -import { type Challenge } from '../../../interfaces/challenge.interface'; -import { type Faction } from '../../../interfaces/faction.interface'; -import { type Team } from '../../../interfaces/team.interface'; -import { type User } from '../../../interfaces/user.interface'; -import { deleteChallenge, validateChallenge } from '../../../services/requests/challenge.service'; -import { Button } from '../../ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; -import { Input } from '../../ui/input'; +import { CheckCircle2, Edit, Search, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import Select from "react-select"; +import Swal from "sweetalert2"; + +import { type Challenge } from "../../../interfaces/challenge.interface"; +import { type Faction } from "../../../interfaces/faction.interface"; +import { type Team } from "../../../interfaces/team.interface"; +import { type User } from "../../../interfaces/user.interface"; +import { deleteChallenge, validateChallenge } from "../../../services/requests/challenge.service"; +import { Button } from "../../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { Input } from "../../ui/input"; interface Props { challenges: Challenge[]; @@ -26,8 +26,9 @@ type ValidationTarget = 'user' | 'team' | 'faction'; const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, factions, users }: Props) => { const [showValidationFormForId, setShowValidationFormForId] = useState(null); const [validationType, setValidationType] = useState(null); - const [selectedTargetId, setSelectedTargetId] = useState(null); - const [searchTerm, setSearchTerm] = useState(''); + const [selectedTargetIds, setSelectedTargetIds] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const filteredChallenges = useMemo(() => { return challenges.filter( @@ -61,14 +62,20 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact } }; - const handleValidate = async () => { - if (!showValidationFormForId || !validationType || !selectedTargetId) return; + const handleValidationCLick = async (c: Challenge) => { + setShowValidationFormForId(c.id); + setValidationType(c.category.toLowerCase() as ValidationTarget); + } + const handleValidate = async () => { + if (!showValidationFormForId || !validationType || selectedTargetIds.length === 0) return; + + for (const targetId of selectedTargetIds) { try { const res = await validateChallenge({ challengeId: showValidationFormForId, type: validationType, - targetId: selectedTargetId, + targetId: targetId, }); Swal.fire({ @@ -81,16 +88,17 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact setShowValidationFormForId(null); setValidationType(null); - setSelectedTargetId(null); + setSelectedTargetIds([]); refreshChallenges(); } catch (err) { console.error('Erreur lors de la validation du challenge', err); Swal.fire({ - icon: 'error', - title: 'Erreur ❌', - text: 'Impossible de valider ce challenge. RĂ©essaie plus tard.', + icon: "error", + title: "Erreur ❌", + text: err as string, }); } + } }; return ( @@ -136,8 +144,9 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact Supprimer @@ -147,30 +156,12 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact

✅ Valider le challenge

- - setSelectedTargetId(Number(option?.value)) - } + onChange={(options) => setSelectedTargetIds(options.map((o) => Number(o.value)))} options={users.map((u: User) => ({ value: u.userId, label: `${u.firstName} ${u.lastName}`, @@ -180,10 +171,9 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact {validationType === 'team' && ( - setSelectedTargetId(Number(option?.value)) - } + onChange={(options) => setSelectedTargetIds(options.map((o) => Number(o.value)))} options={factions.map((f: Faction) => ({ value: f.factionId, label: f.name, @@ -214,7 +203,7 @@ const AdminChallengeList = ({ challenges, refreshChallenges, onEdit, teams, fact onClick={() => { setShowValidationFormForId(null); setValidationType(null); - setSelectedTargetId(null); + setSelectedTargetIds([]); }} className="bg-gray-400 hover:bg-gray-500 text-white"> ❌ Annuler diff --git a/frontend/src/components/Admin/AdminChallenge/adminChallengeEditor.tsx b/frontend/src/components/Admin/AdminChallenge/adminChallengeEditor.tsx index 6afb3a4..143acf4 100644 --- a/frontend/src/components/Admin/AdminChallenge/adminChallengeEditor.tsx +++ b/frontend/src/components/Admin/AdminChallenge/adminChallengeEditor.tsx @@ -42,9 +42,19 @@ const ChallengeEditor = ({ editingChallenge, setEditingChallenge, refreshChallen setEditingChallenge(null); }; + const resetFormPostCreation = () => { + setForm({ title: "", description: "", category: form.category, points: 0 }); + setEditingChallenge(null); + }; + + const handleSubmit = async () => { setLoading(true); try { + if (!form.title || !form.description || !form.category) { + Swal.fire({ icon: "error", title: "Tous les champs doivent ĂȘtre remplis" }); + return; + } if (editingChallenge) { await updateChallenge({ id: editingChallenge.id, ...form }); Swal.fire({ icon: 'success', title: 'Challenge mis Ă  jour !' }); @@ -53,7 +63,7 @@ const ChallengeEditor = ({ editingChallenge, setEditingChallenge, refreshChallen Swal.fire({ icon: 'success', title: 'Challenge créé !' }); } refreshChallenges(); - resetForm(); + resetFormPostCreation(); } catch { Swal.fire({ icon: 'error', title: "Erreur lors de l'enregistrement" }); } finally { diff --git a/frontend/src/components/Admin/AdminChallenge/adminChallengeValidatedList.tsx b/frontend/src/components/Admin/AdminChallenge/adminChallengeValidatedList.tsx index 5bc3e41..799f61a 100644 --- a/frontend/src/components/Admin/AdminChallenge/adminChallengeValidatedList.tsx +++ b/frontend/src/components/Admin/AdminChallenge/adminChallengeValidatedList.tsx @@ -1,6 +1,7 @@ -import { Search } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import Swal from 'sweetalert2'; +import { Search, Trash2, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import Select from "react-select"; +import Swal from "sweetalert2"; import { type ValidatedChallenge } from '../../../interfaces/challenge.interface'; import { unvalidateChallenge } from '../../../services/requests/challenge.service'; @@ -8,13 +9,20 @@ import { Button } from '../../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Input } from '../../ui/input'; + + interface Props { validatedChallenges: ValidatedChallenge[]; fetchValidatedChallenges: () => void | Promise; } -export const AdminValidatedChallengesList = ({ validatedChallenges, fetchValidatedChallenges }: Props) => { - const [search, setSearch] = useState(''); +export const AdminValidatedChallengesList = ({ + validatedChallenges, + fetchValidatedChallenges, +}: Props) => { + const [search, setSearch] = useState(""); + const [showUnvalidationFormForId, setShowUnvalidationFormForId] = useState(null); + const [selectedUnvalidationTargetIds, setSelectedUnvalidationTargetIds] = useState([]); const filtered = useMemo(() => { const q = search.toLowerCase(); @@ -34,36 +42,166 @@ export const AdminValidatedChallengesList = ({ validatedChallenges, fetchValidat ); }, [validatedChallenges, search]); + const groupedValidatedChallenges = useMemo(() => { + const groups = new Map< + number, + { + challenge_id: number; + challenge_name: string; + challenge_categorie: string; + challenge_description: string; + points: number; + validated_at: string; + recipients: Array<{ id: number; label: string }>; + } + >(); + + filtered.forEach((c) => { + const category = c.challenge_categorie?.toLowerCase(); + const recipientId = + category === "user" + ? c.target_user_id + : category === "team" + ? c.target_team_id + : category === "faction" + ? c.target_faction_id + : null; + const recipientLabel = + category === "user" + ? `${c.target_user_firstname ?? ""} ${c.target_user_lastname ?? ""}`.trim() + : category === "team" + ? c.target_team_name + : category === "faction" + ? c.target_faction_name + : null; + + if (!groups.has(c.challenge_id)) { + groups.set(c.challenge_id, { + challenge_id: c.challenge_id, + challenge_name: c.challenge_name, + challenge_categorie: c.challenge_categorie, + challenge_description: c.challenge_description, + points: c.points, + validated_at: c.validated_at, + recipients: [], + }); + } + + const group = groups.get(c.challenge_id); + if (group && recipientId != null && recipientLabel) { + const alreadyAdded = group.recipients.some((r) => r.id === recipientId); + if (!alreadyAdded) { + group.recipients.push({ id: recipientId, label: recipientLabel }); + } + } + }); + + return Array.from(groups.values()); + }, [filtered]); + const handleUnvalidate = async ( - challengeId: number, - factionId: number | null, - teamId: number | null, - userId: number | null, ) => { const confirm = await Swal.fire({ - title: 'Confirmer la dĂ©validation ?', - text: 'Cette action retirera la validation du challenge.', - icon: 'warning', + title: "Confirmer la invalidation ?", + text: "Cette action retirera la validation du challenge.", + icon: "warning", showCancelButton: true, - confirmButtonColor: '#e3342f', - cancelButtonColor: '#6b7280', - confirmButtonText: 'Oui, dĂ©valider', - cancelButtonText: 'Annuler', + confirmButtonColor: "#e3342f", + cancelButtonColor: "#6b7280", + confirmButtonText: "Oui, invalider", + cancelButtonText: "Annuler", }); if (!confirm.isConfirmed) return; + for (const targetId of selectedUnvalidationTargetIds) { + console.log("Invalidation du challenge pour l'ID cible :", targetId); + console.log(validatedChallenges); + const challengeToUnvalidate = validatedChallenges.find( + (c) => c.challenge_id === showUnvalidationFormForId + ); + console.log("Challenge Ă  invalider :", challengeToUnvalidate); + if (!challengeToUnvalidate) { + Swal.fire({ icon: "error", title: "Erreur", text: "Challenge non trouvĂ©." }); + return; + } + try { + const res = await unvalidateChallenge({ + challengeId: showUnvalidationFormForId as number, + factionId: challengeToUnvalidate.target_faction_id ?? 0, + teamId: challengeToUnvalidate.target_team_id ?? 0, + userId: challengeToUnvalidate.target_user_id ?? 0, + }); + console.log("RĂ©ponse de la invalidation :", res); + validatedChallenges = validatedChallenges.filter( + (c) => + c.challenge_id === showUnvalidationFormForId && + c.target_user_id !== targetId && + c.target_team_id !== targetId && + c.target_faction_id !== targetId + ); + } catch { + Swal.fire({ icon: "error", title: "Erreur", text: "Impossible de invalider ce challenge." }); + } + } + + Swal.fire({ + icon: "success", + title: "InvalidĂ©", + text: "Les challenges ont Ă©tĂ© invalidĂ©s.", + timer: 1800, + showConfirmButton: false, + }); + await fetchValidatedChallenges(); + setSelectedUnvalidationTargetIds([]); + setShowUnvalidationFormForId(null); + } + + const handleUnvalidateAll = async () => { + const confirm = await Swal.fire({ + title: "Confirmer la invalidation ?", + text: "Cette action retirera la validation du challenge.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#e3342f", + cancelButtonColor: "#6b7280", + confirmButtonText: "Oui, invalider", + cancelButtonText: "Annuler", + }); - try { - const res = await unvalidateChallenge({ - challengeId, - factionId: factionId ?? 0, - teamId: teamId ?? 0, - userId: userId ?? 0, - }); - Swal.fire({ icon: 'success', title: 'DĂ©validĂ©', text: res.message, timer: 1800, showConfirmButton: false }); - await fetchValidatedChallenges(); - } catch { - Swal.fire({ icon: 'error', title: 'Erreur', text: 'Impossible de dĂ©valider ce challenge.' }); + if (!confirm.isConfirmed) return; + + const challengesToUnvalidate = validatedChallenges.filter( + (c) => c.challenge_id === showUnvalidationFormForId + ); + + for (const challenge of challengesToUnvalidate) { + try { + await unvalidateChallenge({ + challengeId: showUnvalidationFormForId as number, + factionId: challenge.target_faction_id ?? 0, + teamId: challenge.target_team_id ?? 0, + userId: challenge.target_user_id ?? 0, + }); + } catch { + Swal.fire({ + icon: "error", + title: "Erreur", + text: "Impossible de invalider un ou plusieurs challenges.", + }); + return; + } } + + Swal.fire({ + icon: "success", + title: "InvalidĂ©", + text: "Les challenges ont Ă©tĂ© invalidĂ©s.", + timer: 1800, + showConfirmButton: false, + }); + + await fetchValidatedChallenges(); + setSelectedUnvalidationTargetIds([]); + setShowUnvalidationFormForId(null); }; return ( @@ -87,58 +225,88 @@ export const AdminValidatedChallengesList = ({ validatedChallenges, fetchValidat {/* Grille */} - {filtered.length === 0 ? ( -

Aucun challenge validé trouvé.

- ) : ( -
- {filtered.map((c) => ( -
-
-

{c.challenge_name}

-

{c.challenge_categorie}

-

{c.challenge_description}

-
- -
-

- Points : {c.points} -

-

- Validé le : {new Date(c.validated_at).toLocaleDateString()} -

-
- -
-

Destinataire :

- {c.target_faction_name && ( -

{c.target_faction_name}

- )} - {c.target_team_name &&

{c.target_team_name}

} - {(c.target_user_firstname || c.target_user_lastname) && ( -

- {c.target_user_firstname} {c.target_user_lastname} -

+
+ {filtered.length > 0 ? ( + groupedValidatedChallenges.map((group) => ( + + +
+

{group.challenge_name}

+

{group.challenge_description}

+

Catégorie : {group.challenge_categorie}

+

Points : {group.points}

+

Validé le : {new Date(group.validated_at).toLocaleDateString()}

+
+ +
+

Destinataires :

+ {group.recipients.length > 0 ? ( +
    + {group.recipients.map((recipient) => ( +
  • {recipient.label}
  • + ))} +
+ ) : ( +

Aucun destinataire connu.

+ )} +
+ +
+ +
+ + {showUnvalidationFormForId === group.challenge_id && ( + + +

❌ Invalider le challenge

+ +