diff --git a/client/src/App.tsx b/client/src/App.tsx index 1f85b3c..8a25c61 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import AAPDashboard from "./pages/AAPDashboard"; import Settings from "./pages/Settings"; import UsersAdmin from "./pages/UsersAdmin"; import ImportLogs from "./pages/ImportLogs"; +import BoiteAIdees from "./pages/BoiteAIdees"; import { Loader2 } from "lucide-react"; // ─── Guard d'authentification ───────────────────────────────────────────────── @@ -97,6 +98,16 @@ function LogsPage() { ); } +function BoiteAIdeesPage() { + return ( + + + + + + ); +} + // ─── Routeur principal ──────────────────────────────────────────────────────── function Router() { @@ -111,6 +122,7 @@ function Router() { + diff --git a/client/src/components/AppLayout.tsx b/client/src/components/AppLayout.tsx index a9ecd56..b40fa44 100644 --- a/client/src/components/AppLayout.tsx +++ b/client/src/components/AppLayout.tsx @@ -16,6 +16,7 @@ import { RefreshCw, Menu, X, + Lightbulb, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -48,6 +49,14 @@ const NAV_GROUPS: NavGroup[] = [ { label: "Appels à Projets", href: "/aap", icon: }, ], }, + { + label: "Boîte à idées", + icon: , + defaultOpen: true, + items: [ + { label: "Boîte à idées", href: "/boite-a-idees", icon: }, + ], + }, { label: "Administration", icon: , @@ -71,6 +80,7 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) { const [mobileOpen, setMobileOpen] = useState(false); const [openGroups, setOpenGroups] = useState>({ "Tableaux de bord": true, + "Boîte à idées": true, "Administration": false, }); const [location] = useLocation(); diff --git a/client/src/pages/BoiteAIdees.tsx b/client/src/pages/BoiteAIdees.tsx new file mode 100644 index 0000000..2f1e1be --- /dev/null +++ b/client/src/pages/BoiteAIdees.tsx @@ -0,0 +1,465 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { + Lightbulb, + Plus, + MessageSquare, + Calendar, + User, + ChevronDown, + ChevronUp, + Send, + Clock, + CheckCircle2, + XCircle, + AlertCircle, +} from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +type Statut = "ouvert" | "en_cours" | "resolu" | "ferme"; + +const STATUT_CONFIG: Record = { + ouvert: { + label: "Ouvert", + color: "bg-blue-100 text-blue-700 border-blue-200", + icon: , + }, + en_cours: { + label: "En cours", + color: "bg-amber-100 text-amber-700 border-amber-200", + icon: , + }, + resolu: { + label: "Résolu", + color: "bg-emerald-100 text-emerald-700 border-emerald-200", + icon: , + }, + ferme: { + label: "Fermé", + color: "bg-slate-100 text-slate-600 border-slate-200", + icon: , + }, +}; + +type Idea = { + id: number; + userId: number; + userName: string; + titre: string; + message: string; + statut: Statut; + reponseAdmin: string | null; + reponduPar: string | null; + reponduAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +export default function BoiteAIdees() { + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; + + const { data: ideas = [], refetch } = trpc.ideas.list.useQuery(); + const createMutation = trpc.ideas.create.useMutation({ + onSuccess: () => { + toast.success("Votre demande a été envoyée avec succès !"); + setNewDialogOpen(false); + setNewTitre(""); + setNewMessage(""); + refetch(); + }, + onError: (e) => toast.error(e.message), + }); + const reponseMutation = trpc.ideas.repondre.useMutation({ + onSuccess: () => { + toast.success("Réponse enregistrée !"); + setReponseDialogOpen(false); + setReponseText(""); + setSelectedIdea(null); + refetch(); + }, + onError: (e) => toast.error(e.message), + }); + + // État nouvelle demande + const [newDialogOpen, setNewDialogOpen] = useState(false); + const [newTitre, setNewTitre] = useState(""); + const [newMessage, setNewMessage] = useState(""); + + // État réponse admin + const [reponseDialogOpen, setReponseDialogOpen] = useState(false); + const [selectedIdea, setSelectedIdea] = useState(null); + const [reponseText, setReponseText] = useState(""); + const [reponseStatut, setReponseStatut] = useState("resolu"); + + // État expansion des cartes + const [expandedIds, setExpandedIds] = useState>(new Set()); + + // Filtre statut + const [filtreStatut, setFiltreStatut] = useState("tous"); + const [filtreRecherche, setFiltreRecherche] = useState(""); + + const toggleExpand = (id: number) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleRepondre = (idea: Idea) => { + setSelectedIdea(idea); + setReponseText(idea.reponseAdmin ?? ""); + setReponseStatut(idea.statut === "ouvert" ? "en_cours" : idea.statut); + setReponseDialogOpen(true); + }; + + const filteredIdeas = (ideas as Idea[]).filter((idea) => { + const matchStatut = filtreStatut === "tous" || idea.statut === filtreStatut; + const matchRecherche = + !filtreRecherche || + idea.titre.toLowerCase().includes(filtreRecherche.toLowerCase()) || + idea.message.toLowerCase().includes(filtreRecherche.toLowerCase()) || + idea.userName.toLowerCase().includes(filtreRecherche.toLowerCase()); + return matchStatut && matchRecherche; + }); + + const counts = { + tous: (ideas as Idea[]).length, + ouvert: (ideas as Idea[]).filter((i) => i.statut === "ouvert").length, + en_cours: (ideas as Idea[]).filter((i) => i.statut === "en_cours").length, + resolu: (ideas as Idea[]).filter((i) => i.statut === "resolu").length, + ferme: (ideas as Idea[]).filter((i) => i.statut === "ferme").length, + }; + + return ( +
+ {/* En-tête */} +
+
+
+ +
+
+

Boîte à idées

+

+ {isAdmin + ? "Gérez les questions et suggestions des utilisateurs" + : "Posez vos questions et partagez vos suggestions"} +

+
+
+ +
+ + {/* Filtres */} +
+
+ setFiltreRecherche(e.target.value)} + className="bg-white border-slate-200" + /> +
+
+ {(["tous", "ouvert", "en_cours", "resolu", "ferme"] as const).map((s) => ( + + ))} +
+
+ + {/* Liste des demandes */} + {filteredIdeas.length === 0 ? ( +
+ +

Aucune demande pour le moment

+

Soyez le premier à soumettre une question ou une suggestion.

+
+ ) : ( +
+ {filteredIdeas.map((idea) => { + const isExpanded = expandedIds.has(idea.id); + const cfg = STATUT_CONFIG[idea.statut]; + return ( +
+ {/* En-tête de la carte */} +
toggleExpand(idea.id)} + > +
+
+ + {cfg.icon} + {cfg.label} + +

+ {idea.titre} +

+
+
+ + + {idea.userName} + + + + {format(new Date(idea.createdAt), "d MMM yyyy à HH:mm", { locale: fr })} + + {idea.reponseAdmin && ( + + + Réponse disponible + + )} +
+
+
+ {isAdmin && ( + + )} + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Contenu développé */} + {isExpanded && ( +
+ {/* Message */} +
+

+ Message +

+

{idea.message}

+
+ + {/* Réponse admin */} + {idea.reponseAdmin && ( +
+

+ + Réponse de l'équipe + {idea.reponduPar && ( + + — {idea.reponduPar} + + )} + {idea.reponduAt && ( + + ({format(new Date(idea.reponduAt), "d MMM yyyy", { locale: fr })}) + + )} +

+

+ {idea.reponseAdmin} +

+
+ )} +
+ )} +
+ ); + })} +
+ )} + + {/* Dialog : Nouvelle demande */} + + + + + + Nouvelle demande + + +
+
+ + setNewTitre(e.target.value)} + className="mt-1" + /> +
+
+ +