All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m43s
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
addFeedbackBonusPoints,
|
|
markFeedbackAsRead,
|
|
} from "@/actions/admin/feedback";
|
|
import { Button } from "@/components/ui";
|
|
import Avatar from "@/components/ui/Avatar";
|
|
|
|
interface Feedback {
|
|
id: string;
|
|
rating: number;
|
|
comment: string | null;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
event: {
|
|
id: string;
|
|
name: string;
|
|
date: string;
|
|
type: string;
|
|
};
|
|
user: {
|
|
id: string;
|
|
username: string;
|
|
email: string;
|
|
avatar: string | null;
|
|
score: number;
|
|
};
|
|
}
|
|
|
|
interface EventStatistics {
|
|
eventId: string;
|
|
eventName: string;
|
|
eventDate: string | null;
|
|
eventType: string | null;
|
|
averageRating: number;
|
|
feedbackCount: number;
|
|
}
|
|
|
|
interface FeedbackManagementProps {
|
|
initialFeedbacks: Feedback[];
|
|
initialStatistics: EventStatistics[];
|
|
}
|
|
|
|
export default function FeedbackManagement({
|
|
initialFeedbacks,
|
|
initialStatistics,
|
|
}: FeedbackManagementProps) {
|
|
const [feedbacks, setFeedbacks] = useState<Feedback[]>(initialFeedbacks);
|
|
const [statistics, setStatistics] = useState<EventStatistics[]>(initialStatistics);
|
|
const [error, setError] = useState("");
|
|
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
|
|
const [addingPoints, setAddingPoints] = useState<Record<string, boolean>>(
|
|
{}
|
|
);
|
|
const [markingRead, setMarkingRead] = useState<Record<string, boolean>>({});
|
|
|
|
const fetchFeedbacks = async () => {
|
|
try {
|
|
const response = await fetch("/api/admin/feedback");
|
|
if (!response.ok) {
|
|
setError("Erreur lors du chargement des feedbacks");
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
setFeedbacks(data.feedbacks || []);
|
|
setStatistics(data.statistics || []);
|
|
} catch {
|
|
setError("Erreur lors du chargement des feedbacks");
|
|
}
|
|
};
|
|
|
|
const getEventTypeLabel = (type: string) => {
|
|
switch (type) {
|
|
case "ATELIER":
|
|
return "Atelier";
|
|
case "KATA":
|
|
return "Kata";
|
|
case "PRESENTATION":
|
|
return "Présentation";
|
|
case "LEARNING_HOUR":
|
|
return "Learning Hour";
|
|
default:
|
|
return type;
|
|
}
|
|
};
|
|
|
|
const renderStars = (rating: number) => {
|
|
return (
|
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<span
|
|
key={star}
|
|
className={`text-sm sm:text-lg ${
|
|
star <= rating ? "text-pixel-gold" : "text-gray-600"
|
|
}`}
|
|
>
|
|
★
|
|
</span>
|
|
))}
|
|
<span className="text-gray-400 text-xs sm:text-sm ml-1 sm:ml-2">
|
|
({rating}/5)
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const handleAddPoints = async (userId: string, points: number) => {
|
|
const key = `${userId}-${points}`;
|
|
setAddingPoints((prev) => ({ ...prev, [key]: true }));
|
|
setError("");
|
|
|
|
try {
|
|
const result = await addFeedbackBonusPoints(userId, points);
|
|
if (result.success) {
|
|
// Rafraîchir les données pour voir les nouveaux scores
|
|
await fetchFeedbacks();
|
|
// Rafraîchir le score dans le header si l'utilisateur est connecté
|
|
window.dispatchEvent(new Event("refreshUserScore"));
|
|
} else {
|
|
setError(result.error || "Erreur lors de l'ajout des points");
|
|
}
|
|
} catch {
|
|
setError("Erreur lors de l'ajout des points");
|
|
} finally {
|
|
setAddingPoints((prev) => ({ ...prev, [key]: false }));
|
|
}
|
|
};
|
|
|
|
const handleMarkAsRead = async (feedbackId: string, isRead: boolean) => {
|
|
setMarkingRead((prev) => ({ ...prev, [feedbackId]: true }));
|
|
setError("");
|
|
|
|
try {
|
|
const result = await markFeedbackAsRead(feedbackId, isRead);
|
|
if (result.success) {
|
|
// Rafraîchir les données pour voir le nouveau statut
|
|
await fetchFeedbacks();
|
|
} else {
|
|
setError(result.error || "Erreur lors de la mise à jour");
|
|
}
|
|
} catch {
|
|
setError("Erreur lors de la mise à jour");
|
|
} finally {
|
|
setMarkingRead((prev) => ({ ...prev, [feedbackId]: false }));
|
|
}
|
|
};
|
|
|
|
const filteredFeedbacks = (selectedEvent
|
|
? feedbacks.filter((f) => f.event.id === selectedEvent)
|
|
: feedbacks
|
|
).sort((a, b) => {
|
|
// Trier : non lus en premier, puis par date décroissante
|
|
if (a.isRead !== b.isRead) {
|
|
return a.isRead ? 1 : -1;
|
|
}
|
|
return (
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
{/* Statistiques par événement */}
|
|
{statistics.length > 0 && (
|
|
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-3 sm:p-6">
|
|
<h3 className="text-pixel-gold font-bold text-base sm:text-lg mb-4 break-words">
|
|
Statistiques par événement
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
|
{statistics.map((stat) => (
|
|
<div
|
|
key={stat.eventId}
|
|
className={`bg-black/40 border rounded p-3 sm:p-4 cursor-pointer transition ${
|
|
selectedEvent === stat.eventId
|
|
? "border-pixel-gold bg-pixel-gold/10"
|
|
: "border-pixel-gold/30 hover:border-pixel-gold/50"
|
|
}`}
|
|
onClick={() =>
|
|
setSelectedEvent(
|
|
selectedEvent === stat.eventId ? null : stat.eventId
|
|
)
|
|
}
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-2">
|
|
<h4 className="text-white font-semibold text-xs sm:text-sm break-words">
|
|
{stat.eventName}
|
|
</h4>
|
|
<span className="text-pixel-gold text-[10px] sm:text-xs uppercase whitespace-nowrap flex-shrink-0">
|
|
{stat.eventType && getEventTypeLabel(stat.eventType)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{renderStars(Math.round(stat.averageRating))}
|
|
</div>
|
|
<div className="text-gray-400 text-[10px] sm:text-xs">
|
|
Moyenne: {stat.averageRating.toFixed(2)}/5
|
|
</div>
|
|
<div className="text-gray-400 text-[10px] sm:text-xs">
|
|
{stat.feedbackCount} feedback
|
|
{stat.feedbackCount > 1 ? "s" : ""}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{selectedEvent && (
|
|
<button
|
|
onClick={() => setSelectedEvent(null)}
|
|
className="mt-4 text-pixel-gold text-xs sm:text-sm hover:text-orange-400 transition"
|
|
>
|
|
Voir tous les feedbacks
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Liste des feedbacks */}
|
|
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-3 sm:p-6">
|
|
<h3 className="text-pixel-gold font-bold text-base sm:text-lg mb-4 break-words">
|
|
{selectedEvent
|
|
? `Feedbacks pour: ${
|
|
statistics.find((s) => s.eventId === selectedEvent)?.eventName
|
|
}`
|
|
: "Tous les feedbacks"}
|
|
</h3>
|
|
|
|
{error && (
|
|
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-3 sm:px-4 py-2 sm:py-3 rounded text-xs sm:text-sm mb-4">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{filteredFeedbacks.length === 0 ? (
|
|
<p className="text-gray-400 text-center py-8 text-sm">
|
|
Aucun feedback pour le moment
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{filteredFeedbacks.map((feedback) => (
|
|
<div
|
|
key={feedback.id}
|
|
className={`bg-black/40 border rounded p-3 sm:p-4 ${
|
|
feedback.isRead
|
|
? "border-pixel-gold/20 opacity-75"
|
|
: "border-pixel-gold/50 bg-pixel-gold/5"
|
|
}`}
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
{/* En-tête utilisateur avec avatar */}
|
|
<div className="flex items-center gap-2 sm:gap-3 mb-3">
|
|
<Avatar
|
|
src={feedback.user.avatar}
|
|
username={feedback.user.username}
|
|
size="md"
|
|
borderClassName="border-pixel-gold/30"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 mb-1">
|
|
<h4 className="text-white font-semibold text-sm sm:text-base break-words">
|
|
{feedback.user.username}
|
|
</h4>
|
|
<span className="text-pixel-gold font-bold text-xs sm:text-sm">
|
|
{feedback.user.score.toLocaleString("fr-FR")} pts
|
|
</span>
|
|
</div>
|
|
<span className="text-gray-500 text-[10px] sm:text-xs break-all">
|
|
{feedback.user.email}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="text-pixel-gold text-xs sm:text-sm font-semibold break-words">
|
|
{feedback.event.name}
|
|
</div>
|
|
{!feedback.isRead && (
|
|
<span className="bg-pixel-gold/20 text-pixel-gold text-[10px] px-1.5 py-0.5 rounded uppercase font-semibold">
|
|
Non lu
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-gray-500 text-[10px] sm:text-xs mb-2">
|
|
{new Date(feedback.createdAt).toLocaleDateString(
|
|
"fr-FR",
|
|
{
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
{renderStars(feedback.rating)}
|
|
<Button
|
|
variant={feedback.isRead ? "secondary" : "success"}
|
|
size="sm"
|
|
onClick={() =>
|
|
handleMarkAsRead(feedback.id, !feedback.isRead)
|
|
}
|
|
disabled={markingRead[feedback.id]}
|
|
className="text-xs whitespace-nowrap"
|
|
>
|
|
{markingRead[feedback.id]
|
|
? "..."
|
|
: feedback.isRead
|
|
? "Marquer non lu"
|
|
: "Marquer lu"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{feedback.comment && (
|
|
<div className="mt-3 pt-3 border-t border-pixel-gold/20">
|
|
<p className="text-gray-300 text-xs sm:text-sm whitespace-pre-wrap break-words">
|
|
{feedback.comment}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{/* Boutons pour ajouter des points bonus */}
|
|
<div className="mt-3 pt-3 border-t border-pixel-gold/20 flex flex-wrap gap-2">
|
|
<span className="text-gray-400 text-xs sm:text-sm mr-2">
|
|
Points bonus:
|
|
</span>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => handleAddPoints(feedback.user.id, 10)}
|
|
disabled={addingPoints[`${feedback.user.id}-10`]}
|
|
className="text-xs"
|
|
>
|
|
{addingPoints[`${feedback.user.id}-10`] ? "..." : "+10"}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => handleAddPoints(feedback.user.id, 100)}
|
|
disabled={addingPoints[`${feedback.user.id}-100`]}
|
|
className="text-xs"
|
|
>
|
|
{addingPoints[`${feedback.user.id}-100`] ? "..." : "+100"}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => handleAddPoints(feedback.user.id, 1000)}
|
|
disabled={addingPoints[`${feedback.user.id}-1000`]}
|
|
className="text-xs"
|
|
>
|
|
{addingPoints[`${feedback.user.id}-1000`] ? "..." : "+1000"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|