Checkpoint: Boîte à idées : table BDD, API tRPC (créer, lister, répondre, changer statut), page avec liste filtrée par statut et recherche, bouton Nouvelle demande, réponse admin avec statut colorisé, menu dans la sidebar
This commit is contained in:
4
client/public/__manus__/version.json
Normal file
4
client/public/__manus__/version.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "4ba97843",
|
||||
"timestamp": 1776437827804
|
||||
}
|
||||
@@ -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 (
|
||||
<AuthGuard>
|
||||
<DashboardWrapper>
|
||||
<BoiteAIdees />
|
||||
</DashboardWrapper>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routeur principal ────────────────────────────────────────────────────────
|
||||
|
||||
function Router() {
|
||||
@@ -111,6 +122,7 @@ function Router() {
|
||||
<Route path="/admin/settings" component={SettingsPage} />
|
||||
<Route path="/admin/users" component={UsersPage} />
|
||||
<Route path="/admin/logs" component={LogsPage} />
|
||||
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -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: <Target size={16} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Boîte à idées",
|
||||
icon: <Lightbulb size={18} />,
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ label: "Boîte à idées", href: "/boite-a-idees", icon: <Lightbulb size={16} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Administration",
|
||||
icon: <Settings size={18} />,
|
||||
@@ -71,6 +80,7 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
"Tableaux de bord": true,
|
||||
"Boîte à idées": true,
|
||||
"Administration": false,
|
||||
});
|
||||
const [location] = useLocation();
|
||||
|
||||
465
client/src/pages/BoiteAIdees.tsx
Normal file
465
client/src/pages/BoiteAIdees.tsx
Normal file
@@ -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<Statut, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
ouvert: {
|
||||
label: "Ouvert",
|
||||
color: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
icon: <AlertCircle className="h-3 w-3" />,
|
||||
},
|
||||
en_cours: {
|
||||
label: "En cours",
|
||||
color: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
icon: <Clock className="h-3 w-3" />,
|
||||
},
|
||||
resolu: {
|
||||
label: "Résolu",
|
||||
color: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
icon: <CheckCircle2 className="h-3 w-3" />,
|
||||
},
|
||||
ferme: {
|
||||
label: "Fermé",
|
||||
color: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
icon: <XCircle className="h-3 w-3" />,
|
||||
},
|
||||
};
|
||||
|
||||
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<Idea | null>(null);
|
||||
const [reponseText, setReponseText] = useState("");
|
||||
const [reponseStatut, setReponseStatut] = useState<Statut>("resolu");
|
||||
|
||||
// État expansion des cartes
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Filtre statut
|
||||
const [filtreStatut, setFiltreStatut] = useState<string>("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 (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 rounded-xl">
|
||||
<Lightbulb className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Boîte à idées</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{isAdmin
|
||||
? "Gérez les questions et suggestions des utilisateurs"
|
||||
: "Posez vos questions et partagez vos suggestions"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setNewDialogOpen(true)}
|
||||
className="bg-[#0a2463] hover:bg-[#0a2463]/90 text-white gap-2 shadow-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle demande
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Rechercher par titre, message ou demandeur..."
|
||||
value={filtreRecherche}
|
||||
onChange={(e) => setFiltreRecherche(e.target.value)}
|
||||
className="bg-white border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(["tous", "ouvert", "en_cours", "resolu", "ferme"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFiltreStatut(s)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
filtreStatut === s
|
||||
? "bg-[#0a2463] text-white border-[#0a2463]"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:border-[#0a2463]/40"
|
||||
}`}
|
||||
>
|
||||
{s === "tous" ? "Tous" : STATUT_CONFIG[s].label}{" "}
|
||||
<span className="opacity-70">({counts[s]})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des demandes */}
|
||||
{filteredIdeas.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-400">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-lg font-medium">Aucune demande pour le moment</p>
|
||||
<p className="text-sm mt-1">Soyez le premier à soumettre une question ou une suggestion.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredIdeas.map((idea) => {
|
||||
const isExpanded = expandedIds.has(idea.id);
|
||||
const cfg = STATUT_CONFIG[idea.statut];
|
||||
return (
|
||||
<div
|
||||
key={idea.id}
|
||||
className="bg-white rounded-xl border border-slate-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* En-tête de la carte */}
|
||||
<div
|
||||
className="flex items-start gap-4 p-4 cursor-pointer"
|
||||
onClick={() => toggleExpand(idea.id)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${cfg.color}`}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</span>
|
||||
<h3 className="font-semibold text-slate-800 text-sm truncate">
|
||||
{idea.titre}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
{idea.userName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{format(new Date(idea.createdAt), "d MMM yyyy à HH:mm", { locale: fr })}
|
||||
</span>
|
||||
{idea.reponseAdmin && (
|
||||
<span className="flex items-center gap-1 text-emerald-600">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Réponse disponible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs h-7 px-2 border-[#0a2463]/30 text-[#0a2463] hover:bg-[#0a2463]/5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRepondre(idea);
|
||||
}}
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Répondre
|
||||
</Button>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu développé */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-100 px-4 pb-4 pt-3 space-y-3">
|
||||
{/* Message */}
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
|
||||
Message
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">{idea.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Réponse admin */}
|
||||
{idea.reponseAdmin && (
|
||||
<div className="bg-emerald-50 rounded-lg p-3 border border-emerald-100">
|
||||
<p className="text-xs font-medium text-emerald-600 mb-1.5 uppercase tracking-wide flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Réponse de l'équipe
|
||||
{idea.reponduPar && (
|
||||
<span className="text-emerald-500 normal-case ml-1">
|
||||
— {idea.reponduPar}
|
||||
</span>
|
||||
)}
|
||||
{idea.reponduAt && (
|
||||
<span className="text-emerald-400 normal-case ml-1">
|
||||
({format(new Date(idea.reponduAt), "d MMM yyyy", { locale: fr })})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-emerald-800 whitespace-pre-wrap">
|
||||
{idea.reponseAdmin}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog : Nouvelle demande */}
|
||||
<Dialog open={newDialogOpen} onOpenChange={setNewDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lightbulb className="h-5 w-5 text-amber-500" />
|
||||
Nouvelle demande
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="titre" className="text-sm font-medium">
|
||||
Titre <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="titre"
|
||||
placeholder="Résumez votre demande en quelques mots..."
|
||||
value={newTitre}
|
||||
onChange={(e) => setNewTitre(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-sm font-medium">
|
||||
Message <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
placeholder="Décrivez votre question, suggestion ou remarque en détail..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
rows={5}
|
||||
className="mt-1 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!newTitre.trim() || !newMessage.trim()) {
|
||||
toast.error("Veuillez remplir tous les champs");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({ titre: newTitre.trim(), message: newMessage.trim() });
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
className="bg-[#0a2463] hover:bg-[#0a2463]/90 text-white gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{createMutation.isPending ? "Envoi..." : "Envoyer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog : Réponse admin */}
|
||||
<Dialog open={reponseDialogOpen} onOpenChange={setReponseDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-[#0a2463]" />
|
||||
Répondre à la demande
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedIdea && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Rappel de la demande */}
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-slate-500 mb-1">Demande de {selectedIdea.userName}</p>
|
||||
<p className="text-sm font-semibold text-slate-800">{selectedIdea.titre}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 line-clamp-2">{selectedIdea.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Statut */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Statut</Label>
|
||||
<Select
|
||||
value={reponseStatut}
|
||||
onValueChange={(v) => setReponseStatut(v as Statut)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ouvert">Ouvert</SelectItem>
|
||||
<SelectItem value="en_cours">En cours</SelectItem>
|
||||
<SelectItem value="resolu">Résolu</SelectItem>
|
||||
<SelectItem value="ferme">Fermé</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Réponse */}
|
||||
<div>
|
||||
<Label htmlFor="reponse" className="text-sm font-medium">
|
||||
Réponse <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reponse"
|
||||
placeholder="Rédigez votre réponse..."
|
||||
value={reponseText}
|
||||
onChange={(e) => setReponseText(e.target.value)}
|
||||
rows={5}
|
||||
className="mt-1 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReponseDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!reponseText.trim()) {
|
||||
toast.error("Veuillez rédiger une réponse");
|
||||
return;
|
||||
}
|
||||
reponseMutation.mutate({
|
||||
id: selectedIdea!.id,
|
||||
reponseAdmin: reponseText.trim(),
|
||||
statut: reponseStatut,
|
||||
});
|
||||
}}
|
||||
disabled={reponseMutation.isPending}
|
||||
className="bg-[#0a2463] hover:bg-[#0a2463]/90 text-white gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{reponseMutation.isPending ? "Envoi..." : "Enregistrer la réponse"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user