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:
Manus
2026-04-17 10:57:07 -04:00
parent b36a049ca5
commit 535dd19188
11 changed files with 1311 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
{
"version": "4ba97843",
"timestamp": 1776437827804
}

View File

@@ -12,6 +12,7 @@ import AAPDashboard from "./pages/AAPDashboard";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import UsersAdmin from "./pages/UsersAdmin"; import UsersAdmin from "./pages/UsersAdmin";
import ImportLogs from "./pages/ImportLogs"; import ImportLogs from "./pages/ImportLogs";
import BoiteAIdees from "./pages/BoiteAIdees";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
// ─── Guard d'authentification ───────────────────────────────────────────────── // ─── Guard d'authentification ─────────────────────────────────────────────────
@@ -97,6 +98,16 @@ function LogsPage() {
); );
} }
function BoiteAIdeesPage() {
return (
<AuthGuard>
<DashboardWrapper>
<BoiteAIdees />
</DashboardWrapper>
</AuthGuard>
);
}
// ─── Routeur principal ──────────────────────────────────────────────────────── // ─── Routeur principal ────────────────────────────────────────────────────────
function Router() { function Router() {
@@ -111,6 +122,7 @@ function Router() {
<Route path="/admin/settings" component={SettingsPage} /> <Route path="/admin/settings" component={SettingsPage} />
<Route path="/admin/users" component={UsersPage} /> <Route path="/admin/users" component={UsersPage} />
<Route path="/admin/logs" component={LogsPage} /> <Route path="/admin/logs" component={LogsPage} />
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
<Route path="/404" component={NotFound} /> <Route path="/404" component={NotFound} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

View File

@@ -16,6 +16,7 @@ import {
RefreshCw, RefreshCw,
Menu, Menu,
X, X,
Lightbulb,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -48,6 +49,14 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Appels à Projets", href: "/aap", icon: <Target size={16} /> }, { 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", label: "Administration",
icon: <Settings size={18} />, icon: <Settings size={18} />,
@@ -71,6 +80,7 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
"Tableaux de bord": true, "Tableaux de bord": true,
"Boîte à idées": true,
"Administration": false, "Administration": false,
}); });
const [location] = useLocation(); const [location] = useLocation();

View 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>
);
}

View File

@@ -0,0 +1,14 @@
CREATE TABLE `ideas` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`userName` varchar(255) NOT NULL,
`titre` varchar(512) NOT NULL,
`message` text NOT NULL,
`statut` enum('ouvert','en_cours','resolu','ferme') NOT NULL DEFAULT 'ouvert',
`reponseAdmin` text,
`reponduPar` varchar(255),
`reponduAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `ideas_id` PRIMARY KEY(`id`)
);

View File

@@ -0,0 +1,663 @@
{
"version": "5",
"dialect": "mysql",
"id": "46aedace-2016-466b-a1f0-6dc05f8f383e",
"prevId": "8f5ba9eb-1f6d-4c0f-8eeb-262ea8031bee",
"tables": {
"aap_items": {
"name": "aap_items",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"dedupKey": {
"name": "dedupKey",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"titre": {
"name": "titre",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"categorie": {
"name": "categorie",
"type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"region": {
"name": "region",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"departement": {
"name": "departement",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dateCloture": {
"name": "dateCloture",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"datePublication": {
"name": "datePublication",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lien": {
"name": "lien",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"importedAt": {
"name": "importedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"aap_items_id": {
"name": "aap_items_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"aap_items_dedupKey_unique": {
"name": "aap_items_dedupKey_unique",
"columns": [
"dedupKey"
]
}
},
"checkConstraint": {}
},
"app_settings": {
"name": "app_settings",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"key": {
"name": "key",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"app_settings_id": {
"name": "app_settings_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"app_settings_key_unique": {
"name": "app_settings_key_unique",
"columns": [
"key"
]
}
},
"checkConstraint": {}
},
"ideas": {
"name": "ideas",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userName": {
"name": "userName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"titre": {
"name": "titre",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('ouvert','en_cours','resolu','ferme')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ouvert'"
},
"reponseAdmin": {
"name": "reponseAdmin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponduPar": {
"name": "reponduPar",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponduAt": {
"name": "reponduAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"ideas_id": {
"name": "ideas_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"import_logs": {
"name": "import_logs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"fileType": {
"name": "fileType",
"type": "enum('veille','aap')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('success','partial','error')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"totalRows": {
"name": "totalRows",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"newRows": {
"name": "newRows",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"skippedRows": {
"name": "skippedRows",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startedAt": {
"name": "startedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"completedAt": {
"name": "completedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"import_logs_id": {
"name": "import_logs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"local_users": {
"name": "local_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','user','readonly')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"local_users_id": {
"name": "local_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"local_users_email_unique": {
"name": "local_users_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
},
"veille_items": {
"name": "veille_items",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"dedupKey": {
"name": "dedupKey",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"titre": {
"name": "titre",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"categorie": {
"name": "categorie",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"niveau": {
"name": "niveau",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"territoire": {
"name": "territoire",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"resume": {
"name": "resume",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"passage": {
"name": "passage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lien": {
"name": "lien",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typeVeille": {
"name": "typeVeille",
"type": "enum('reglementaire','concurrentielle','technologique','generale')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"datePublication": {
"name": "datePublication",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"importedAt": {
"name": "importedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"veille_items_id": {
"name": "veille_items_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"veille_items_dedupKey_unique": {
"name": "veille_items_dedupKey_unique",
"columns": [
"dedupKey"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1773671031809, "when": 1773671031809,
"tag": "0001_old_shocker", "tag": "0001_old_shocker",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1776437589031,
"tag": "0002_sticky_the_watchers",
"breakpoints": true
} }
] ]
} }

View File

@@ -115,3 +115,22 @@ export const importLogs = mysqlTable("import_logs", {
export type ImportLog = typeof importLogs.$inferSelect; export type ImportLog = typeof importLogs.$inferSelect;
export type InsertImportLog = typeof importLogs.$inferInsert; export type InsertImportLog = typeof importLogs.$inferInsert;
// ─── Boîte à idées ───────────────────────────────────────────────────────────
export const ideas = mysqlTable("ideas", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
userName: varchar("userName", { length: 255 }).notNull(),
titre: varchar("titre", { length: 512 }).notNull(),
message: text("message").notNull(),
statut: mysqlEnum("statut", ["ouvert", "en_cours", "resolu", "ferme"]).default("ouvert").notNull(),
reponseAdmin: text("reponseAdmin"),
reponduPar: varchar("reponduPar", { length: 255 }),
reponduAt: timestamp("reponduAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Idea = typeof ideas.$inferSelect;
export type InsertIdea = typeof ideas.$inferInsert;

View File

@@ -9,6 +9,8 @@ import {
appSettings, appSettings,
importLogs, importLogs,
InsertLocalUser, InsertLocalUser,
ideas,
InsertIdea,
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from "./_core/env"; import { ENV } from "./_core/env";
@@ -315,3 +317,45 @@ export async function getImportStats() {
totalNewRows, totalNewRows,
}; };
} }
// ─── Boîte à idées ────────────────────────────────────────────────────────────
export async function createIdea(data: InsertIdea) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(ideas).values(data);
}
export async function getAllIdeas() {
const db = await getDb();
if (!db) return [];
return db.select().from(ideas).orderBy(desc(ideas.createdAt));
}
export async function getIdeasByUser(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(ideas).where(eq(ideas.userId, userId)).orderBy(desc(ideas.createdAt));
}
export async function repondreIdea(
id: number,
reponseAdmin: string,
reponduPar: string,
statut: "ouvert" | "en_cours" | "resolu" | "ferme"
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(ideas).set({
reponseAdmin,
reponduPar,
reponduAt: new Date(),
statut,
}).where(eq(ideas.id, id));
}
export async function updateIdeaStatut(id: number, statut: "ouvert" | "en_cours" | "resolu" | "ferme") {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(ideas).set({ statut }).where(eq(ideas.id, id));
}

View File

@@ -17,6 +17,11 @@ import {
createLocalUser, createLocalUser,
updateLocalUser, updateLocalUser,
deleteLocalUser, deleteLocalUser,
createIdea,
getAllIdeas,
getIdeasByUser,
repondreIdea,
updateIdeaStatut,
} from "./db"; } from "./db";
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
@@ -238,6 +243,67 @@ export const appRouter = router({
return { success: true }; return { success: true };
}), }),
}), }),
// ─── Boîte à idées ───────────────────────────────────────────────────────────
ideas: router({
// Créer une nouvelle idée / question
create: protectedProcedure
.input(
z.object({
titre: z.string().min(3).max(512),
message: z.string().min(10),
})
)
.mutation(async ({ input, ctx }) => {
await createIdea({
userId: ctx.user.id,
userName: ctx.user.name ?? ctx.user.email ?? "Utilisateur",
titre: input.titre,
message: input.message,
});
return { success: true };
}),
// Lister toutes les idées (admin) ou les siennes (user)
list: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "admin") {
return getAllIdeas();
}
return getIdeasByUser(ctx.user.id);
}),
// Répondre à une idée (admin uniquement)
repondre: adminProcedure
.input(
z.object({
id: z.number().int().positive(),
reponseAdmin: z.string().min(1),
statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
})
)
.mutation(async ({ input, ctx }) => {
await repondreIdea(
input.id,
input.reponseAdmin,
ctx.user.name ?? ctx.user.email ?? "Admin",
input.statut
);
return { success: true };
}),
// Changer le statut (admin uniquement)
updateStatut: adminProcedure
.input(
z.object({
id: z.number().int().positive(),
statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
})
)
.mutation(async ({ input }) => {
await updateIdeaStatut(input.id, input.statut);
return { success: true };
}),
}),
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -46,3 +46,10 @@
- [x] Page Login : supprimer l'encart affichant les identifiants du compte par défaut - [x] Page Login : supprimer l'encart affichant les identifiants du compte par défaut
- [x] Login : accepter un identifiant (e-mail ou nom d'utilisateur) au lieu d'un e-mail obligatoire - [x] Login : accepter un identifiant (e-mail ou nom d'utilisateur) au lieu d'un e-mail obligatoire
- [x] Page Login : logo Itinova en haut, "powered by" + logo Santinova en bas - [x] Page Login : logo Itinova en haut, "powered by" + logo Santinova en bas
## Boîte à idées
- [x] BDD : table ideas (id, userId, titre, message, statut, réponseAdmin, createdAt, updatedAt)
- [x] API tRPC : créer une idée, lister toutes les idées (admin), mes idées (user), répondre (admin)
- [x] Page BoiteAIdees : liste avec date, titre, demandeur, texte, statut, réponse admin
- [x] Bouton "Nouvelle demande" en haut à droite ouvrant une boîte de dialogue
- [x] Menu "Boîte à idées" dans la sidebar accessible à tous les utilisateurs