From 6952ae6907240d8036ad40caab02726b3e84cd75 Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 27 May 2026 16:39:18 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Impl=C3=A9mentation=20compl=C3=A8?= =?UTF-8?q?te=20de=20la=20fonctionnalit=C3=A9=20"Gestion=20des=20plans=20d?= =?UTF-8?q?e=20formations"=20:=20-=20Sch=C3=A9ma=20DB=20:=203=20nouvelles?= =?UTF-8?q?=20tables=20(catalogueFormation,=20planFormation,=20planFormati?= =?UTF-8?q?onItem)=20-=20Backend=20tRPC=20:=20routers=20catalogue=20et=20p?= =?UTF-8?q?lanFormation=20(CRUD,=20import=20bulk=20CSV,=20suggestions,=20t?= =?UTF-8?q?oggle=20valid=C3=A9e=20Itinova)=20-=20Frontend=20:=20page=20Pla?= =?UTF-8?q?ns=20de=20formation=20(liste=20par=20=C3=A9tablissement/ann?= =?UTF-8?q?=C3=A9e,=20CRUD,=20soumission/validation)=20-=20Frontend=20:=20?= =?UTF-8?q?page=20D=C3=A9tail=20plan=20(items,=20liaison=20catalogue,=20su?= =?UTF-8?q?ggestions=20automatiques)=20-=20Frontend=20:=20page=20Catalogue?= =?UTF-8?q?=20de=20formation=20(liste,=20filtres=20th=C3=A8me/Itinova/OPCO?= =?UTF-8?q?,=20import=20CSV,=20badges)=20-=20Menu=20sidebar=20:=20nouvelle?= =?UTF-8?q?=20section=20"Plans=20de=20formations"=20avec=20sous-menus=20-?= =?UTF-8?q?=20Tests=20unitaires=20:=2013=20tests=20(scoring=20suggestions,?= =?UTF-8?q?=20validation=20donn=C3=A9es,=20parsing=20CSV)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + client/src/App.tsx | 6 + client/src/components/DashboardLayout.tsx | 12 +- client/src/pages/CatalogueFormation.tsx | 650 +++++ client/src/pages/PlanFormation.tsx | 469 ++++ client/src/pages/PlanFormationDetail.tsx | 539 +++++ drizzle/meta/0020_snapshot.json | 2667 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 107 + server/__tests__/planFormation.test.ts | 175 ++ server/routers.ts | 5 +- server/routers/catalogue.ts | 308 +++ server/routers/planFormation.ts | 306 +++ todo.md | 45 + 14 files changed, 5295 insertions(+), 2 deletions(-) create mode 100644 client/src/pages/CatalogueFormation.tsx create mode 100644 client/src/pages/PlanFormation.tsx create mode 100644 client/src/pages/PlanFormationDetail.tsx create mode 100644 drizzle/meta/0020_snapshot.json create mode 100644 server/__tests__/planFormation.test.ts create mode 100644 server/routers/catalogue.ts create mode 100644 server/routers/planFormation.ts diff --git a/.gitignore b/.gitignore index 82c46c3..fd61792 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,4 @@ temp/ backups/ *.sql *.dump +.project-config.json diff --git a/client/src/App.tsx b/client/src/App.tsx index c31bdd8..98b2d60 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -44,6 +44,9 @@ import EmargementFormateurScan from "./pages/EmargementFormateurScan"; import SignerAttestation from "./pages/SignerAttestation"; import ForgotPassword from "./pages/ForgotPassword"; import ResetPassword from "./pages/ResetPassword"; +import PlanFormation from "./pages/PlanFormation"; +import PlanFormationDetail from "./pages/PlanFormationDetail"; +import CatalogueFormation from "./pages/CatalogueFormation"; function Router() { return ( @@ -87,6 +90,9 @@ function Router() { + + + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 7c86e1c..2c82bbb 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText, QrCode, Shield } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText, QrCode, Shield, BookOpen, Library } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -36,6 +36,7 @@ const getCategoryColor = (sectionTitle: string) => { "Traçabilité": "text-purple-500", "Analyses": "text-pink-500", "Gestion technique": "text-red-500", + "Plans de formations": "text-teal-600", }; return colors[sectionTitle] || "text-gray-500"; }; @@ -48,6 +49,7 @@ const getCategoryGradient = (sectionTitle: string) => { "Traçabilité": "from-purple-300 to-purple-400", "Analyses": "from-pink-300 to-pink-400", "Gestion technique": "from-red-300 to-red-400", + "Plans de formations": "from-teal-300 to-teal-400", }; return gradients[sectionTitle] || "from-gray-300 to-gray-400"; }; @@ -60,6 +62,7 @@ const getCategoryBgColor = (sectionTitle: string) => { "Traçabilité": "bg-purple-50/80 hover:bg-purple-100/90", "Analyses": "bg-pink-50/80 hover:bg-pink-100/90", "Gestion technique": "bg-red-50/80 hover:bg-red-100/90", + "Plans de formations": "bg-teal-50/80 hover:bg-teal-100/90", }; return bgColors[sectionTitle] || "bg-gray-50/80 hover:bg-gray-100/90"; }; @@ -114,6 +117,13 @@ const menuSections = [ { icon: BarChart3, label: "Statistiques des rappels", path: "/admin/rappels/statistiques" }, ] }, + { + title: "Plans de formations", + items: [ + { icon: BookOpen, label: "Plans de formations", path: "/admin/plans-formation" }, + { icon: Library, label: "Catalogue de formation", path: "/admin/catalogue-formation" }, + ] + }, { title: "Gestion technique", items: [ diff --git a/client/src/pages/CatalogueFormation.tsx b/client/src/pages/CatalogueFormation.tsx new file mode 100644 index 0000000..915ee60 --- /dev/null +++ b/client/src/pages/CatalogueFormation.tsx @@ -0,0 +1,650 @@ +import { useState, useRef, useMemo } from "react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import DashboardLayout from "@/components/DashboardLayout"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "sonner"; +import { + Plus, Pencil, Trash2, Library, CheckCircle2, + Upload, Search, Filter, Loader2, Star, Euro, + X, ChevronDown, ChevronUp +} from "lucide-react"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQuotes = !inQuotes; + } else if ((ch === "," || ch === ";") && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += ch; + } + } + result.push(current.trim()); + return result; +} + +function parseCsvContent(content: string) { + const lines = content.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return []; + const headers = parseCsvLine(lines[0]).map(h => h.toLowerCase().replace(/[^a-z]/g, "")); + return lines.slice(1).map(line => { + const values = parseCsvLine(line); + const obj: Record = {}; + headers.forEach((h, i) => { obj[h] = values[i] ?? ""; }); + return obj; + }).filter(r => r[headers[0]]); +} + +function mapCsvRow(row: Record) { + const get = (...keys: string[]) => { + for (const k of keys) { + const found = Object.keys(row).find(rk => rk.includes(k)); + if (found && row[found]) return row[found]; + } + return undefined; + }; + return { + intitule: get("intitul", "titre", "nom", "formation") ?? "", + theme: get("theme", "categorie", "domaine"), + motsCles: get("motscles", "mots", "tags", "keywords"), + duree: get("duree", "dur"), + prestataire: get("prestataire", "organisme", "fournisseur"), + description: get("description", "detail"), + objectifs: get("objectifs", "objectif"), + publicConcerne: get("public", "destinataire", "cible"), + opcoEligible: ["oui", "true", "1", "x"].includes((get("opco", "eligible") ?? "").toLowerCase()), + budgetOpco: get("budget", "cout", "prix", "tarif"), + referenceOpco: get("reference", "ref", "code"), + }; +} + +// ─── Composant principal ────────────────────────────────────────────────────── + +export default function CatalogueFormation() { + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; + + // Filtres + const [search, setSearch] = useState(""); + const [filtreTheme, setFiltreTheme] = useState("all"); + const [filtreValidee, setFiltreValidee] = useState("all"); + const [filtreOpco, setFiltreOpco] = useState("all"); + const [showFilters, setShowFilters] = useState(false); + + // Dialogs + const [showCreate, setShowCreate] = useState(false); + const [showImport, setShowImport] = useState(false); + const [editFormation, setEditFormation] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + // Import CSV + const fileRef = useRef(null); + const [csvRows, setCsvRows] = useState[]>([]); + const [csvFileName, setCsvFileName] = useState(""); + + // Formulaire + const emptyForm = { + intitule: "", theme: "", motsCles: "", duree: "", prestataire: "", + description: "", objectifs: "", publicConcerne: "", + validateeItinova: false, opcoEligible: false, budgetOpco: "", referenceOpco: "", + }; + const [form, setForm] = useState(emptyForm); + + const utils = trpc.useUtils(); + + const { data: formations = [], isLoading } = trpc.catalogue.list.useQuery({}); + const { data: themes = [] } = trpc.catalogue.themes.useQuery(); + + const createM = trpc.catalogue.create.useMutation({ + onSuccess: () => { + toast.success("Formation ajoutée au catalogue"); + utils.catalogue.list.invalidate(); + utils.catalogue.themes.invalidate(); + setShowCreate(false); + setForm(emptyForm); + }, + onError: (err) => toast.error(err.message), + }); + + const updateM = trpc.catalogue.update.useMutation({ + onSuccess: () => { + toast.success("Formation mise à jour"); + utils.catalogue.list.invalidate(); + utils.catalogue.themes.invalidate(); + setEditFormation(null); + }, + onError: (err) => toast.error(err.message), + }); + + const deleteM = trpc.catalogue.delete.useMutation({ + onSuccess: () => { + toast.success("Formation supprimée du catalogue"); + utils.catalogue.list.invalidate(); + }, + onError: (err) => toast.error(err.message), + }); + + const toggleValideeM = trpc.catalogue.toggleValideeItinova.useMutation({ + onSuccess: () => { + utils.catalogue.list.invalidate(); + }, + onError: (err) => toast.error(err.message), + }); + + const importM = trpc.catalogue.importBulk.useMutation({ + onSuccess: (data) => { + toast.success(`${data.inserted} formation(s) importée(s) avec succès`); + utils.catalogue.list.invalidate(); + utils.catalogue.themes.invalidate(); + setShowImport(false); + setCsvRows([]); + setCsvFileName(""); + }, + onError: (err) => toast.error(err.message), + }); + + // Filtrage + const filtered = useMemo(() => { + return formations.filter(f => { + if (search) { + const s = search.toLowerCase(); + if (!f.intitule.toLowerCase().includes(s) && + !(f.theme ?? "").toLowerCase().includes(s) && + !(f.prestataire ?? "").toLowerCase().includes(s) && + !(f.motsCles ?? "").toLowerCase().includes(s)) return false; + } + if (filtreTheme !== "all" && f.theme !== filtreTheme) return false; + if (filtreValidee === "oui" && !f.validateeItinova) return false; + if (filtreValidee === "non" && f.validateeItinova) return false; + if (filtreOpco === "oui" && !f.opcoEligible) return false; + if (filtreOpco === "non" && f.opcoEligible) return false; + return true; + }); + }, [formations, search, filtreTheme, filtreValidee, filtreOpco]); + + // Stats + const stats = useMemo(() => ({ + total: formations.length, + validees: formations.filter(f => f.validateeItinova).length, + opco: formations.filter(f => f.opcoEligible).length, + }), [formations]); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setCsvFileName(file.name); + const reader = new FileReader(); + reader.onload = (ev) => { + const content = ev.target?.result as string; + const rows = parseCsvContent(content); + const mapped = rows.map(mapCsvRow).filter(r => r.intitule); + setCsvRows(mapped); + if (mapped.length === 0) { + toast.error("Aucune ligne valide trouvée. Vérifiez le format du fichier."); + } else { + toast.success(`${mapped.length} formation(s) détectée(s) — vérifiez avant d'importer`); + } + }; + reader.readAsText(file, "UTF-8"); + } + + function handleImport() { + if (csvRows.length === 0) return; + importM.mutate({ formations: csvRows.filter(r => r.intitule), source: "import_csv" }); + } + + function handleCreate() { + if (!form.intitule.trim()) { toast.error("L'intitulé est obligatoire"); return; } + createM.mutate({ ...form, budgetOpco: form.budgetOpco || undefined }); + } + + function handleUpdate() { + if (!editFormation) return; + const { id: _id, ...editData } = editFormation; + updateM.mutate({ id: editFormation.id, ...editData, budgetOpco: editFormation.budgetOpco || undefined }); + } + + return ( + +
+ {/* En-tête */} +
+
+
+ +
+
+

Catalogue de formation

+

Référentiel partagé des formations disponibles

+
+
+ {isAdmin && ( +
+ + +
+ )} +
+ + {/* Stats */} +
+ + +

Total

+

{stats.total}

+
+
+ + +

Validées Itinova

+

{stats.validees}

+
+
+ + +

Éligibles OPCO

+

{stats.opco}

+
+
+
+ + {/* Barre de recherche et filtres */} + + +
+
+ + setSearch(e.target.value)} + /> + {search && ( + + )} +
+ +
+ {showFilters && ( +
+ + + + {filtered.length} résultat(s) +
+ )} +
+
+ + {/* Liste */} + {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( + + + +

Aucune formation dans le catalogue

+ {isAdmin && ( +
+ + +
+ )} +
+
+ ) : ( +
+ {filtered.map(f => ( + + +
+
+
+ {f.intitule} + {f.validateeItinova && ( + + + Validée Itinova + + )} + {f.opcoEligible && ( + + + OPCO + + )} + {f.theme && ( + {f.theme} + )} +
+
+ {f.duree && ⏱ {f.duree}} + {f.prestataire && 🏢 {f.prestataire}} + {f.publicConcerne && 👥 {f.publicConcerne}} + {f.budgetOpco && 💰 OPCO : {f.budgetOpco} €} + {f.motsCles && 🏷 {f.motsCles}} +
+ {/* Détails dépliables */} + {expandedId === f.id && ( +
+ {f.description &&

Description : {f.description}

} + {f.objectifs &&

Objectifs : {f.objectifs}

} + {f.referenceOpco &&

Référence OPCO : {f.referenceOpco}

} +

Source : {f.source}

+
+ )} + {(f.description || f.objectifs) && ( + + )} +
+ {isAdmin && ( +
+ {/* Toggle Validée Itinova */} + + + +
+ )} +
+
+
+ ))} +
+ )} +
+ + {/* Dialog : Créer */} + + + + + + Ajouter au catalogue + + + + + + + + + + + {/* Dialog : Modifier */} + setEditFormation(null)}> + + + Modifier la formation + + {editFormation && ( + setEditFormation(prev => prev ? { ...prev, ...fn(prev) } : null)} /> + )} + + + + + + + + {/* Dialog : Import CSV */} + + + + + + Importer depuis un fichier CSV + + +
+
+ Format attendu : fichier CSV (séparateur virgule ou point-virgule) avec en-têtes. +
Colonnes reconnues : intitule, theme, duree, prestataire, public, description, objectifs, motsCles, opco, budget, reference. +
+
fileRef.current?.click()} + > + +

{csvFileName || "Cliquez pour sélectionner un fichier CSV"}

+

Formats acceptés : .csv, .txt

+ +
+ {csvRows.length > 0 && ( +
+

{csvRows.length} formation(s) prête(s) à importer :

+
+ {csvRows.slice(0, 20).map((r, i) => ( +
+ {i + 1}. + {r.intitule} + {r.theme && {r.theme}} + {r.duree && {r.duree}} + {r.opcoEligible && OPCO} +
+ ))} + {csvRows.length > 20 && ( +
... et {csvRows.length - 20} autres
+ )} +
+
+ )} +
+ + + + +
+
+
+ ); +} + +// ─── Sous-composant formulaire ──────────────────────────────────────────────── + +type FormType = { + intitule: string; theme: string; motsCles: string; duree: string; prestataire: string; + description: string; objectifs: string; publicConcerne: string; + validateeItinova: boolean; opcoEligible: boolean; budgetOpco: string; referenceOpco: string; +}; + +function FormFields({ form, setForm }: { form: FormType; setForm: (fn: (prev: FormType) => FormType) => void }) { + const set = (key: keyof FormType, value: string | boolean) => + setForm(f => ({ ...f, [key]: value })); + + return ( +
+
+ + set("intitule", e.target.value)} placeholder="Intitulé de la formation" /> +
+
+
+ + set("theme", e.target.value)} placeholder="Ex: Sécurité, Soins..." /> +
+
+ + set("duree", e.target.value)} placeholder="Ex: 2 jours, 14h" /> +
+
+
+ + set("motsCles", e.target.value)} placeholder="Ex: gestes, postures, manutention" /> +
+
+
+ + set("prestataire", e.target.value)} placeholder="Organisme de formation" /> +
+
+ + set("publicConcerne", e.target.value)} placeholder="Ex: Aides-soignants" /> +
+
+
+ +