Checkpoint: Implémentation complète de la fonctionnalité "Gestion des plans de formations" :
- Schéma DB : 3 nouvelles tables (catalogueFormation, planFormation, planFormationItem) - Backend tRPC : routers catalogue et planFormation (CRUD, import bulk CSV, suggestions, toggle validée Itinova) - Frontend : page Plans de formation (liste par établissement/année, CRUD, soumission/validation) - Frontend : page Détail plan (items, liaison catalogue, suggestions automatiques) - Frontend : page Catalogue de formation (liste, filtres thème/Itinova/OPCO, import CSV, badges) - Menu sidebar : nouvelle section "Plans de formations" avec sous-menus - Tests unitaires : 13 tests (scoring suggestions, validation données, parsing CSV)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -110,3 +110,4 @@ temp/
|
|||||||
backups/
|
backups/
|
||||||
*.sql
|
*.sql
|
||||||
*.dump
|
*.dump
|
||||||
|
.project-config.json
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import EmargementFormateurScan from "./pages/EmargementFormateurScan";
|
|||||||
import SignerAttestation from "./pages/SignerAttestation";
|
import SignerAttestation from "./pages/SignerAttestation";
|
||||||
import ForgotPassword from "./pages/ForgotPassword";
|
import ForgotPassword from "./pages/ForgotPassword";
|
||||||
import ResetPassword from "./pages/ResetPassword";
|
import ResetPassword from "./pages/ResetPassword";
|
||||||
|
import PlanFormation from "./pages/PlanFormation";
|
||||||
|
import PlanFormationDetail from "./pages/PlanFormationDetail";
|
||||||
|
import CatalogueFormation from "./pages/CatalogueFormation";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +90,9 @@ function Router() {
|
|||||||
<Route path={"/formateur/emargement"} component={FormateurEmargement} />
|
<Route path={"/formateur/emargement"} component={FormateurEmargement} />
|
||||||
<Route path={"/emargement/:token"} component={EmargementScan} />
|
<Route path={"/emargement/:token"} component={EmargementScan} />
|
||||||
<Route path={"/emargement-formateur/:token"} component={EmargementFormateurScan} />
|
<Route path={"/emargement-formateur/:token"} component={EmargementFormateurScan} />
|
||||||
|
<Route path={"/admin/plans-formation"} component={PlanFormation} />
|
||||||
|
<Route path={"/admin/plans-formation/:id"} component={PlanFormationDetail} />
|
||||||
|
<Route path={"/admin/catalogue-formation"} component={CatalogueFormation} />
|
||||||
<Route path={"/signer-attestation/:id"} component={SignerAttestation} />
|
<Route path={"/signer-attestation/:id"} component={SignerAttestation} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -36,6 +36,7 @@ const getCategoryColor = (sectionTitle: string) => {
|
|||||||
"Traçabilité": "text-purple-500",
|
"Traçabilité": "text-purple-500",
|
||||||
"Analyses": "text-pink-500",
|
"Analyses": "text-pink-500",
|
||||||
"Gestion technique": "text-red-500",
|
"Gestion technique": "text-red-500",
|
||||||
|
"Plans de formations": "text-teal-600",
|
||||||
};
|
};
|
||||||
return colors[sectionTitle] || "text-gray-500";
|
return colors[sectionTitle] || "text-gray-500";
|
||||||
};
|
};
|
||||||
@@ -48,6 +49,7 @@ const getCategoryGradient = (sectionTitle: string) => {
|
|||||||
"Traçabilité": "from-purple-300 to-purple-400",
|
"Traçabilité": "from-purple-300 to-purple-400",
|
||||||
"Analyses": "from-pink-300 to-pink-400",
|
"Analyses": "from-pink-300 to-pink-400",
|
||||||
"Gestion technique": "from-red-300 to-red-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";
|
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",
|
"Traçabilité": "bg-purple-50/80 hover:bg-purple-100/90",
|
||||||
"Analyses": "bg-pink-50/80 hover:bg-pink-100/90",
|
"Analyses": "bg-pink-50/80 hover:bg-pink-100/90",
|
||||||
"Gestion technique": "bg-red-50/80 hover:bg-red-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";
|
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" },
|
{ 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",
|
title: "Gestion technique",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
650
client/src/pages/CatalogueFormation.tsx
Normal file
650
client/src/pages/CatalogueFormation.tsx
Normal file
@@ -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<string, string> = {};
|
||||||
|
headers.forEach((h, i) => { obj[h] = values[i] ?? ""; });
|
||||||
|
return obj;
|
||||||
|
}).filter(r => r[headers[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCsvRow(row: Record<string, string>) {
|
||||||
|
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 | typeof emptyForm & { id: number }>(null);
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Import CSV
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [csvRows, setCsvRows] = useState<ReturnType<typeof mapCsvRow>[]>([]);
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-teal-100 rounded-lg">
|
||||||
|
<Library className="h-6 w-6 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Catalogue de formation</h1>
|
||||||
|
<p className="text-sm text-gray-500">Référentiel partagé des formations disponibles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowImport(true)}>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Importer CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowCreate(true)} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card className="border-l-4 border-l-teal-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Total</p>
|
||||||
|
<p className="text-2xl font-bold text-teal-700">{stats.total}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-4 border-l-green-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-green-600 uppercase tracking-wide">Validées Itinova</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">{stats.validees}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-4 border-l-blue-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-blue-600 uppercase tracking-wide">Éligibles OPCO</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">{stats.opco}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de recherche et filtres */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Rechercher par intitulé, thème, prestataire, mots-clés..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button className="absolute right-3 top-1/2 -translate-y-1/2" onClick={() => setSearch("")}>
|
||||||
|
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filtres
|
||||||
|
{showFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showFilters && (
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||||
|
<Select value={filtreTheme} onValueChange={setFiltreTheme}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="Thème" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les thèmes</SelectItem>
|
||||||
|
{themes.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filtreValidee} onValueChange={setFiltreValidee}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="Validée Itinova" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes</SelectItem>
|
||||||
|
<SelectItem value="oui">Validées Itinova</SelectItem>
|
||||||
|
<SelectItem value="non">Non validées</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filtreOpco} onValueChange={setFiltreOpco}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="OPCO" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes</SelectItem>
|
||||||
|
<SelectItem value="oui">Éligibles OPCO</SelectItem>
|
||||||
|
<SelectItem value="non">Non éligibles</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-gray-500 self-center ml-auto">{filtered.length} résultat(s)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Library className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 text-lg">Aucune formation dans le catalogue</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-3 justify-center mt-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowImport(true)}>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Importer CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowCreate(true)} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter manuellement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map(f => (
|
||||||
|
<Card key={f.id} className={`hover:shadow-md transition-all border-l-4 ${f.validateeItinova ? "border-l-teal-500" : "border-l-gray-200"}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-900">{f.intitule}</span>
|
||||||
|
{f.validateeItinova && (
|
||||||
|
<Badge className="bg-teal-600 text-white text-xs">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
Validée Itinova
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{f.opcoEligible && (
|
||||||
|
<Badge className="bg-blue-100 text-blue-700 border-blue-200 text-xs">
|
||||||
|
<Euro className="h-3 w-3 mr-1" />
|
||||||
|
OPCO
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{f.theme && (
|
||||||
|
<Badge variant="outline" className="text-gray-500 text-xs">{f.theme}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
{f.duree && <span>⏱ {f.duree}</span>}
|
||||||
|
{f.prestataire && <span>🏢 {f.prestataire}</span>}
|
||||||
|
{f.publicConcerne && <span>👥 {f.publicConcerne}</span>}
|
||||||
|
{f.budgetOpco && <span>💰 OPCO : {f.budgetOpco} €</span>}
|
||||||
|
{f.motsCles && <span className="text-gray-400">🏷 {f.motsCles}</span>}
|
||||||
|
</div>
|
||||||
|
{/* Détails dépliables */}
|
||||||
|
{expandedId === f.id && (
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-gray-600 border-t pt-3">
|
||||||
|
{f.description && <p><strong>Description :</strong> {f.description}</p>}
|
||||||
|
{f.objectifs && <p><strong>Objectifs :</strong> {f.objectifs}</p>}
|
||||||
|
{f.referenceOpco && <p><strong>Référence OPCO :</strong> {f.referenceOpco}</p>}
|
||||||
|
<p className="text-xs text-gray-400">Source : {f.source}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(f.description || f.objectifs) && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-teal-600 hover:text-teal-800 mt-1 flex items-center gap-1"
|
||||||
|
onClick={() => setExpandedId(expandedId === f.id ? null : f.id)}
|
||||||
|
>
|
||||||
|
{expandedId === f.id ? <><ChevronUp className="h-3 w-3" />Réduire</> : <><ChevronDown className="h-3 w-3" />Voir détails</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{/* Toggle Validée Itinova */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={f.validateeItinova ? "text-teal-600 hover:text-teal-800 hover:bg-teal-50" : "text-gray-400 hover:text-teal-600 hover:bg-teal-50"}
|
||||||
|
title={f.validateeItinova ? "Retirer la validation Itinova" : "Marquer comme validée Itinova"}
|
||||||
|
onClick={() => toggleValideeM.mutate({ id: f.id, valeur: !f.validateeItinova })}
|
||||||
|
>
|
||||||
|
<Star className={`h-4 w-4 ${f.validateeItinova ? "fill-teal-500" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-blue-500 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
onClick={() => setEditFormation({
|
||||||
|
id: f.id,
|
||||||
|
intitule: f.intitule,
|
||||||
|
theme: f.theme ?? "",
|
||||||
|
motsCles: f.motsCles ?? "",
|
||||||
|
duree: f.duree ?? "",
|
||||||
|
prestataire: f.prestataire ?? "",
|
||||||
|
description: f.description ?? "",
|
||||||
|
objectifs: f.objectifs ?? "",
|
||||||
|
publicConcerne: f.publicConcerne ?? "",
|
||||||
|
validateeItinova: f.validateeItinova,
|
||||||
|
opcoEligible: f.opcoEligible,
|
||||||
|
budgetOpco: f.budgetOpco ?? "",
|
||||||
|
referenceOpco: f.referenceOpco ?? "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Supprimer cette formation du catalogue ?")) {
|
||||||
|
deleteM.mutate({ id: f.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog : Créer */}
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Library className="h-5 w-5 text-teal-600" />
|
||||||
|
Ajouter au catalogue
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<FormFields form={form} setForm={setForm} />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreate(false)}>Annuler</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={createM.isPending} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
{createM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog : Modifier */}
|
||||||
|
<Dialog open={!!editFormation} onOpenChange={() => setEditFormation(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier la formation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editFormation && (
|
||||||
|
<FormFields form={editFormation} setForm={(fn) => setEditFormation(prev => prev ? { ...prev, ...fn(prev) } : null)} />
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditFormation(null)}>Annuler</Button>
|
||||||
|
<Button onClick={handleUpdate} disabled={updateM.isPending} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
{updateM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog : Import CSV */}
|
||||||
|
<Dialog open={showImport} onOpenChange={setShowImport}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="h-5 w-5 text-teal-600" />
|
||||||
|
Importer depuis un fichier CSV
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||||
|
<strong>Format attendu :</strong> fichier CSV (séparateur virgule ou point-virgule) avec en-têtes.
|
||||||
|
<br />Colonnes reconnues : <code>intitule</code>, <code>theme</code>, <code>duree</code>, <code>prestataire</code>, <code>public</code>, <code>description</code>, <code>objectifs</code>, <code>motsCles</code>, <code>opco</code>, <code>budget</code>, <code>reference</code>.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-teal-300 rounded-lg p-8 text-center cursor-pointer hover:bg-teal-50 transition-colors"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-10 w-10 text-teal-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-600">{csvFileName || "Cliquez pour sélectionner un fichier CSV"}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Formats acceptés : .csv, .txt</p>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,.txt" className="hidden" onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
{csvRows.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-2">{csvRows.length} formation(s) prête(s) à importer :</p>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||||
|
{csvRows.slice(0, 20).map((r, i) => (
|
||||||
|
<div key={i} className="p-2 text-sm flex items-center gap-2">
|
||||||
|
<span className="text-gray-400 w-6 text-right">{i + 1}.</span>
|
||||||
|
<span className="font-medium text-gray-900">{r.intitule}</span>
|
||||||
|
{r.theme && <Badge variant="outline" className="text-xs">{r.theme}</Badge>}
|
||||||
|
{r.duree && <span className="text-gray-400 text-xs">{r.duree}</span>}
|
||||||
|
{r.opcoEligible && <Badge className="bg-blue-100 text-blue-700 text-xs">OPCO</Badge>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{csvRows.length > 20 && (
|
||||||
|
<div className="p-2 text-center text-xs text-gray-400">... et {csvRows.length - 20} autres</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setShowImport(false); setCsvRows([]); setCsvFileName(""); }}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={csvRows.length === 0 || importM.isPending}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
{importM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Importer {csvRows.length > 0 ? `(${csvRows.length})` : ""}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Intitulé *</Label>
|
||||||
|
<Input value={form.intitule} onChange={e => set("intitule", e.target.value)} placeholder="Intitulé de la formation" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Thème / Domaine</Label>
|
||||||
|
<Input value={form.theme} onChange={e => set("theme", e.target.value)} placeholder="Ex: Sécurité, Soins..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Durée</Label>
|
||||||
|
<Input value={form.duree} onChange={e => set("duree", e.target.value)} placeholder="Ex: 2 jours, 14h" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Mots-clés</Label>
|
||||||
|
<Input value={form.motsCles} onChange={e => set("motsCles", e.target.value)} placeholder="Ex: gestes, postures, manutention" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Prestataire</Label>
|
||||||
|
<Input value={form.prestataire} onChange={e => set("prestataire", e.target.value)} placeholder="Organisme de formation" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Public concerné</Label>
|
||||||
|
<Input value={form.publicConcerne} onChange={e => set("publicConcerne", e.target.value)} placeholder="Ex: Aides-soignants" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea value={form.description} onChange={e => set("description", e.target.value)} rows={2} placeholder="Description de la formation..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Objectifs pédagogiques</Label>
|
||||||
|
<Textarea value={form.objectifs} onChange={e => set("objectifs", e.target.value)} rows={2} placeholder="Objectifs visés..." />
|
||||||
|
</div>
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-teal-700 font-semibold">Validée Itinova</Label>
|
||||||
|
<p className="text-xs text-gray-500">Formation recommandée par Itinova</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.validateeItinova}
|
||||||
|
onCheckedChange={v => set("validateeItinova", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-blue-700 font-semibold">Éligible OPCO</Label>
|
||||||
|
<p className="text-xs text-gray-500">Financement OPCO possible</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.opcoEligible}
|
||||||
|
onCheckedChange={v => set("opcoEligible", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.opcoEligible && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Budget OPCO (€)</Label>
|
||||||
|
<Input value={form.budgetOpco} onChange={e => set("budgetOpco", e.target.value)} placeholder="Ex: 1200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Référence OPCO</Label>
|
||||||
|
<Input value={form.referenceOpco} onChange={e => set("referenceOpco", e.target.value)} placeholder="Ex: REF-2024-001" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
469
client/src/pages/PlanFormation.tsx
Normal file
469
client/src/pages/PlanFormation.tsx
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import { useState, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Plus, Pencil, Trash2, Eye, ChevronRight, BookOpen,
|
||||||
|
CheckCircle2, Clock, XCircle, Send, AlertCircle, Loader2,
|
||||||
|
Users, Calendar, Building2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PlanStatut = "brouillon" | "soumis" | "valide" | "rejete";
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function statutBadge(statut: PlanStatut) {
|
||||||
|
switch (statut) {
|
||||||
|
case "brouillon": return <Badge variant="outline" className="text-gray-600 border-gray-300 bg-gray-50"><Clock className="h-3 w-3 mr-1" />Brouillon</Badge>;
|
||||||
|
case "soumis": return <Badge className="bg-blue-100 text-blue-700 border-blue-200"><Send className="h-3 w-3 mr-1" />Soumis</Badge>;
|
||||||
|
case "valide": return <Badge className="bg-green-100 text-green-700 border-green-200"><CheckCircle2 className="h-3 w-3 mr-1" />Validé</Badge>;
|
||||||
|
case "rejete": return <Badge className="bg-red-100 text-red-700 border-red-200"><XCircle className="h-3 w-3 mr-1" />Rejeté</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANNEES = Array.from({ length: 8 }, (_, i) => new Date().getFullYear() - 1 + i);
|
||||||
|
|
||||||
|
// ─── Composant principal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PlanFormation() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
const [filtreAnnee, setFiltreAnnee] = useState<string>("all");
|
||||||
|
const [filtreStatut, setFiltreStatut] = useState<string>("all");
|
||||||
|
const [filtreEtab, setFiltreEtab] = useState<string>("");
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showValider, setShowValider] = useState(false);
|
||||||
|
const [planAValider, setPlanAValider] = useState<number | null>(null);
|
||||||
|
const [decisionValidation, setDecisionValidation] = useState<"valide" | "rejete">("valide");
|
||||||
|
const [notesValidation, setNotesValidation] = useState("");
|
||||||
|
|
||||||
|
// Formulaire création
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
codeEtablissement: "",
|
||||||
|
nomEtablissement: "",
|
||||||
|
annee: new Date().getFullYear(),
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: plans = [], isLoading } = trpc.planFormation.list.useQuery({});
|
||||||
|
|
||||||
|
const createMutation = trpc.planFormation.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Plan de formation créé avec succès");
|
||||||
|
utils.planFormation.list.invalidate();
|
||||||
|
setShowCreate(false);
|
||||||
|
setFormData({ codeEtablissement: "", nomEtablissement: "", annee: new Date().getFullYear(), notes: "" });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const soumettreM = trpc.planFormation.soumettre.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Plan soumis pour validation");
|
||||||
|
utils.planFormation.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validerM = trpc.planFormation.valider.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(decisionValidation === "valide" ? "Plan validé" : "Plan rejeté");
|
||||||
|
utils.planFormation.list.invalidate();
|
||||||
|
setShowValider(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteM = trpc.planFormation.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Plan supprimé");
|
||||||
|
utils.planFormation.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrage
|
||||||
|
const plansFiltres = useMemo(() => {
|
||||||
|
return plans.filter(p => {
|
||||||
|
if (filtreAnnee !== "all" && p.annee !== parseInt(filtreAnnee)) return false;
|
||||||
|
if (filtreStatut !== "all" && p.statut !== filtreStatut) return false;
|
||||||
|
if (filtreEtab && !p.codeEtablissement.toLowerCase().includes(filtreEtab.toLowerCase())
|
||||||
|
&& !(p.nomEtablissement ?? "").toLowerCase().includes(filtreEtab.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [plans, filtreAnnee, filtreStatut, filtreEtab]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: plans.length,
|
||||||
|
brouillons: plans.filter(p => p.statut === "brouillon").length,
|
||||||
|
soumis: plans.filter(p => p.statut === "soumis").length,
|
||||||
|
valides: plans.filter(p => p.statut === "valide").length,
|
||||||
|
}), [plans]);
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!formData.codeEtablissement.trim()) {
|
||||||
|
toast.error("Le code établissement est obligatoire");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSoumettre(id: number) {
|
||||||
|
if (confirm("Soumettre ce plan pour validation par l'équipe Itinova ?")) {
|
||||||
|
soumettreM.mutate({ id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValider() {
|
||||||
|
if (!planAValider) return;
|
||||||
|
validerM.mutate({ id: planAValider, decision: decisionValidation, notesValidation });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: number) {
|
||||||
|
if (confirm("Supprimer définitivement ce plan de formation ?")) {
|
||||||
|
deleteM.mutate({ id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-teal-100 rounded-lg">
|
||||||
|
<BookOpen className="h-6 w-6 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Plans de formations</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestion des plans de formation par établissement et par année</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreate(true)} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nouveau plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="border-l-4 border-l-gray-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Total</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-700">{stats.total}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-4 border-l-gray-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Brouillons</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-600">{stats.brouillons}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-4 border-l-blue-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-blue-600 uppercase tracking-wide">Soumis</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">{stats.soumis}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-4 border-l-green-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-green-600 uppercase tracking-wide">Validés</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">{stats.valides}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<Select value={filtreAnnee} onValueChange={setFiltreAnnee}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="Année" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes</SelectItem>
|
||||||
|
{ANNEES.map(a => (
|
||||||
|
<SelectItem key={a} value={String(a)}>{a}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-gray-400" />
|
||||||
|
<Select value={filtreStatut} onValueChange={setFiltreStatut}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="Statut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||||
|
<SelectItem value="brouillon">Brouillon</SelectItem>
|
||||||
|
<SelectItem value="soumis">Soumis</SelectItem>
|
||||||
|
<SelectItem value="valide">Validé</SelectItem>
|
||||||
|
<SelectItem value="rejete">Rejeté</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filtrer par établissement..."
|
||||||
|
value={filtreEtab}
|
||||||
|
onChange={e => setFiltreEtab(e.target.value)}
|
||||||
|
className="w-56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 ml-auto">{plansFiltres.length} plan(s)</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste des plans */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||||
|
</div>
|
||||||
|
) : plansFiltres.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<BookOpen className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 text-lg">Aucun plan de formation</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">Créez votre premier plan en cliquant sur "Nouveau plan"</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plansFiltres.map(plan => (
|
||||||
|
<Card key={plan.id} className="hover:shadow-md transition-shadow border-l-4 border-l-teal-400">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-teal-100 flex items-center justify-center">
|
||||||
|
<span className="text-teal-700 font-bold text-sm">{plan.annee}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{plan.nomEtablissement || plan.codeEtablissement}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
<span className="text-gray-600">Plan {plan.annee}</span>
|
||||||
|
{statutBadge(plan.statut as PlanStatut)}
|
||||||
|
</div>
|
||||||
|
{plan.nomEtablissement && (
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Code : {plan.codeEtablissement}</p>
|
||||||
|
)}
|
||||||
|
{plan.notes && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1 truncate max-w-lg">{plan.notes}</p>
|
||||||
|
)}
|
||||||
|
{plan.notesValidation && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1 italic">Note Itinova : {plan.notesValidation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Soumettre (si brouillon) */}
|
||||||
|
{plan.statut === "brouillon" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-blue-600 border-blue-200 hover:bg-blue-50"
|
||||||
|
onClick={() => handleSoumettre(plan.id)}
|
||||||
|
disabled={soumettreM.isPending}
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3 mr-1" />
|
||||||
|
Soumettre
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Valider/Rejeter (admin, si soumis) */}
|
||||||
|
{isAdmin && plan.statut === "soumis" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
onClick={() => {
|
||||||
|
setPlanAValider(plan.id);
|
||||||
|
setDecisionValidation("valide");
|
||||||
|
setNotesValidation("");
|
||||||
|
setShowValider(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
Valider
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Voir le détail */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-teal-600 border-teal-200 hover:bg-teal-50"
|
||||||
|
onClick={() => setLocation(`/admin/plans-formation/${plan.id}`)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Détail
|
||||||
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
{/* Supprimer (brouillon uniquement ou admin) */}
|
||||||
|
{(plan.statut === "brouillon" || isAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => handleDelete(plan.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog : Créer un plan */}
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-5 w-5 text-teal-600" />
|
||||||
|
Nouveau plan de formation
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Code établissement *</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: ETA001"
|
||||||
|
value={formData.codeEtablissement}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, codeEtablissement: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Année *</Label>
|
||||||
|
<Select
|
||||||
|
value={String(formData.annee)}
|
||||||
|
onValueChange={v => setFormData(f => ({ ...f, annee: parseInt(v) }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ANNEES.map(a => (
|
||||||
|
<SelectItem key={a} value={String(a)}>{a}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Nom de l'établissement</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: EHPAD Les Tilleuls"
|
||||||
|
value={formData.nomEtablissement}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, nomEtablissement: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Notes générales</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Contexte, priorités, besoins particuliers..."
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreate(false)}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Créer le plan
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog : Valider/Rejeter */}
|
||||||
|
<Dialog open={showValider} onOpenChange={setShowValider}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Décision de validation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant={decisionValidation === "valide" ? "default" : "outline"}
|
||||||
|
className={decisionValidation === "valide" ? "bg-green-600 hover:bg-green-700 flex-1" : "flex-1"}
|
||||||
|
onClick={() => setDecisionValidation("valide")}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
Valider
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={decisionValidation === "rejete" ? "default" : "outline"}
|
||||||
|
className={decisionValidation === "rejete" ? "bg-red-600 hover:bg-red-700 flex-1" : "flex-1"}
|
||||||
|
onClick={() => setDecisionValidation("rejete")}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 mr-2" />
|
||||||
|
Rejeter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Notes de validation (optionnel)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Commentaires, suggestions, motif de rejet..."
|
||||||
|
value={notesValidation}
|
||||||
|
onChange={e => setNotesValidation(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowValider(false)}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleValider}
|
||||||
|
disabled={validerM.isPending}
|
||||||
|
className={decisionValidation === "valide" ? "bg-green-600 hover:bg-green-700" : "bg-red-600 hover:bg-red-700"}
|
||||||
|
>
|
||||||
|
{validerM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Confirmer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
539
client/src/pages/PlanFormationDetail.tsx
Normal file
539
client/src/pages/PlanFormationDetail.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import { useState } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Plus, Pencil, Trash2, ArrowLeft, BookOpen,
|
||||||
|
CheckCircle2, Clock, XCircle, Send, Loader2,
|
||||||
|
Users, Star, Lightbulb, Library
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useLocation, useParams } from "wouter";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ItemStatut = "en_attente" | "valide" | "refuse" | "suggere";
|
||||||
|
type PlanStatut = "brouillon" | "soumis" | "valide" | "rejete";
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function itemStatutBadge(statut: ItemStatut) {
|
||||||
|
switch (statut) {
|
||||||
|
case "en_attente": return <Badge variant="outline" className="text-gray-600 border-gray-300"><Clock className="h-3 w-3 mr-1" />En attente</Badge>;
|
||||||
|
case "valide": return <Badge className="bg-green-100 text-green-700 border-green-200"><CheckCircle2 className="h-3 w-3 mr-1" />Validé</Badge>;
|
||||||
|
case "refuse": return <Badge className="bg-red-100 text-red-700 border-red-200"><XCircle className="h-3 w-3 mr-1" />Refusé</Badge>;
|
||||||
|
case "suggere": return <Badge className="bg-amber-100 text-amber-700 border-amber-200"><Lightbulb className="h-3 w-3 mr-1" />Suggéré</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioriteLabel(p: number) {
|
||||||
|
if (p === 1) return <span className="text-red-600 font-semibold text-xs">Haute</span>;
|
||||||
|
if (p === 2) return <span className="text-amber-600 text-xs">Moyenne</span>;
|
||||||
|
return <span className="text-gray-400 text-xs">Basse</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Composant principal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PlanFormationDetail() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const planId = parseInt(params.id ?? "0");
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const [showAddItem, setShowAddItem] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<null | { id: number; intitule: string; duree: string; prestataire: string; publicConcerne: string; nbPersonnes: string; description: string; budgetEstime: string; priorite: number }>(null);
|
||||||
|
const [showSuggest, setShowSuggest] = useState(false);
|
||||||
|
const [suggestItemId, setSuggestItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Formulaire item
|
||||||
|
const emptyItem = { intitule: "", duree: "", prestataire: "", publicConcerne: "", nbPersonnes: "", description: "", budgetEstime: "", priorite: 2 };
|
||||||
|
const [itemForm, setItemForm] = useState(emptyItem);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: plan, isLoading } = trpc.planFormation.getById.useQuery(
|
||||||
|
{ id: planId },
|
||||||
|
{ enabled: planId > 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItemM = trpc.planFormation.addItem.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Formation ajoutée au plan");
|
||||||
|
utils.planFormation.getById.invalidate({ id: planId });
|
||||||
|
setShowAddItem(false);
|
||||||
|
setItemForm(emptyItem);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateItemM = trpc.planFormation.updateItem.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Formation mise à jour");
|
||||||
|
utils.planFormation.getById.invalidate({ id: planId });
|
||||||
|
setEditItem(null);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteItemM = trpc.planFormation.deleteItem.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Formation retirée du plan");
|
||||||
|
utils.planFormation.getById.invalidate({ id: planId });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suggestions du catalogue
|
||||||
|
const suggestItem = plan?.items?.find(i => i.id === suggestItemId);
|
||||||
|
const { data: suggestions } = trpc.catalogue.suggest.useQuery(
|
||||||
|
{
|
||||||
|
intitule: suggestItem?.intitule ?? "",
|
||||||
|
theme: undefined,
|
||||||
|
},
|
||||||
|
{ enabled: showSuggest && !!suggestItem }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleAddItem() {
|
||||||
|
if (!itemForm.intitule.trim()) {
|
||||||
|
toast.error("L'intitulé est obligatoire");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addItemM.mutate({
|
||||||
|
planId,
|
||||||
|
intitule: itemForm.intitule,
|
||||||
|
duree: itemForm.duree || undefined,
|
||||||
|
prestataire: itemForm.prestataire || undefined,
|
||||||
|
publicConcerne: itemForm.publicConcerne || undefined,
|
||||||
|
nbPersonnes: itemForm.nbPersonnes ? parseInt(itemForm.nbPersonnes) : undefined,
|
||||||
|
description: itemForm.description || undefined,
|
||||||
|
budgetEstime: itemForm.budgetEstime || undefined,
|
||||||
|
priorite: itemForm.priorite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateItem() {
|
||||||
|
if (!editItem) return;
|
||||||
|
updateItemM.mutate({
|
||||||
|
id: editItem.id,
|
||||||
|
intitule: editItem.intitule,
|
||||||
|
duree: editItem.duree || undefined,
|
||||||
|
prestataire: editItem.prestataire || undefined,
|
||||||
|
publicConcerne: editItem.publicConcerne || undefined,
|
||||||
|
nbPersonnes: editItem.nbPersonnes ? parseInt(editItem.nbPersonnes) : undefined,
|
||||||
|
description: editItem.description || undefined,
|
||||||
|
budgetEstime: editItem.budgetEstime || undefined,
|
||||||
|
priorite: editItem.priorite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLinkCatalogue(catalogueId: number) {
|
||||||
|
if (!suggestItemId) return;
|
||||||
|
updateItemM.mutate({ id: suggestItemId, catalogueFormationId: catalogueId });
|
||||||
|
setShowSuggest(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="p-6 text-center text-gray-500">Plan introuvable</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = plan.statut === "brouillon" || plan.statut === "rejete";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLocation("/admin/plans-formation")}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="p-2 bg-teal-100 rounded-lg">
|
||||||
|
<BookOpen className="h-5 w-5 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">
|
||||||
|
{plan.nomEtablissement || plan.codeEtablissement} — Plan {plan.annee}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-gray-400">Code : {plan.codeEtablissement}</span>
|
||||||
|
{plan.statut === "brouillon" && <Badge variant="outline" className="text-gray-600 border-gray-300 text-xs"><Clock className="h-3 w-3 mr-1" />Brouillon</Badge>}
|
||||||
|
{plan.statut === "soumis" && <Badge className="bg-blue-100 text-blue-700 border-blue-200 text-xs"><Send className="h-3 w-3 mr-1" />Soumis</Badge>}
|
||||||
|
{plan.statut === "valide" && <Badge className="bg-green-100 text-green-700 border-green-200 text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Validé</Badge>}
|
||||||
|
{plan.statut === "rejete" && <Badge className="bg-red-100 text-red-700 border-red-200 text-xs"><XCircle className="h-3 w-3 mr-1" />Rejeté</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<Button onClick={() => setShowAddItem(true)} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter une formation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{plan.notes && (
|
||||||
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-blue-700"><strong>Notes :</strong> {plan.notes}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{plan.notesValidation && (
|
||||||
|
<Card className="bg-amber-50 border-amber-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-amber-700"><strong>Note Itinova :</strong> {plan.notesValidation}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des formations du plan */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center justify-between">
|
||||||
|
<span>Formations demandées ({plan.items?.length ?? 0})</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{!plan.items || plan.items.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
<BookOpen className="h-10 w-10 mx-auto mb-3 text-gray-200" />
|
||||||
|
<p>Aucune formation dans ce plan</p>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="outline" className="mt-3" onClick={() => setShowAddItem(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter une formation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{plan.items.map((item) => (
|
||||||
|
<div key={item.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-gray-900">{item.intitule}</span>
|
||||||
|
{itemStatutBadge(item.statut as ItemStatut)}
|
||||||
|
{item.priorite && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="h-3 w-3 text-amber-400" />
|
||||||
|
{prioriteLabel(item.priorite)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.catalogueFormation && (
|
||||||
|
<Badge className="bg-teal-100 text-teal-700 border-teal-200 text-xs">
|
||||||
|
<Library className="h-3 w-3 mr-1" />
|
||||||
|
Catalogue
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
{item.duree && <span>⏱ {item.duree}</span>}
|
||||||
|
{item.prestataire && <span>🏢 {item.prestataire}</span>}
|
||||||
|
{item.publicConcerne && <span><Users className="h-3 w-3 inline mr-0.5" />{item.publicConcerne}</span>}
|
||||||
|
{item.nbPersonnes && <span>👥 {item.nbPersonnes} pers.</span>}
|
||||||
|
{item.budgetEstime && <span>💰 {item.budgetEstime} €</span>}
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 line-clamp-2">{item.description}</p>
|
||||||
|
)}
|
||||||
|
{/* Suggestion catalogue liée */}
|
||||||
|
{item.catalogueFormation && (
|
||||||
|
<div className="mt-2 p-2 bg-teal-50 rounded text-xs text-teal-700 border border-teal-200">
|
||||||
|
<strong>Catalogue Itinova :</strong> {item.catalogueFormation.intitule}
|
||||||
|
{item.catalogueFormation.validateeItinova && (
|
||||||
|
<Badge className="ml-2 bg-teal-600 text-white text-xs">✓ Validée Itinova</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-amber-500 hover:text-amber-700 hover:bg-amber-50"
|
||||||
|
title="Rechercher dans le catalogue"
|
||||||
|
onClick={() => {
|
||||||
|
setSuggestItemId(item.id);
|
||||||
|
setShowSuggest(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lightbulb className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-blue-500 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
onClick={() => setEditItem({
|
||||||
|
id: item.id,
|
||||||
|
intitule: item.intitule,
|
||||||
|
duree: item.duree ?? "",
|
||||||
|
prestataire: item.prestataire ?? "",
|
||||||
|
publicConcerne: item.publicConcerne ?? "",
|
||||||
|
nbPersonnes: item.nbPersonnes ? String(item.nbPersonnes) : "",
|
||||||
|
description: item.description ?? "",
|
||||||
|
budgetEstime: item.budgetEstime ?? "",
|
||||||
|
priorite: item.priorite ?? 2,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Retirer cette formation du plan ?")) {
|
||||||
|
deleteItemM.mutate({ id: item.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog : Ajouter une formation */}
|
||||||
|
<Dialog open={showAddItem} onOpenChange={setShowAddItem}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajouter une formation au plan</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Intitulé *</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: Formation gestes et postures"
|
||||||
|
value={itemForm.intitule}
|
||||||
|
onChange={e => setItemForm(f => ({ ...f, intitule: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Durée</Label>
|
||||||
|
<Input placeholder="Ex: 2 jours" value={itemForm.duree} onChange={e => setItemForm(f => ({ ...f, duree: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Nb personnes</Label>
|
||||||
|
<Input type="number" placeholder="Ex: 10" value={itemForm.nbPersonnes} onChange={e => setItemForm(f => ({ ...f, nbPersonnes: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Prestataire souhaité</Label>
|
||||||
|
<Input placeholder="Ex: Organisme XYZ" value={itemForm.prestataire} onChange={e => setItemForm(f => ({ ...f, prestataire: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Public concerné</Label>
|
||||||
|
<Input placeholder="Ex: Aides-soignants" value={itemForm.publicConcerne} onChange={e => setItemForm(f => ({ ...f, publicConcerne: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Budget estimé (€)</Label>
|
||||||
|
<Input placeholder="Ex: 1500" value={itemForm.budgetEstime} onChange={e => setItemForm(f => ({ ...f, budgetEstime: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Priorité</Label>
|
||||||
|
<Select value={String(itemForm.priorite)} onValueChange={v => setItemForm(f => ({ ...f, priorite: parseInt(v) }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Haute</SelectItem>
|
||||||
|
<SelectItem value="2">Moyenne</SelectItem>
|
||||||
|
<SelectItem value="3">Basse</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description / Objectifs</Label>
|
||||||
|
<Textarea placeholder="Détails, objectifs pédagogiques..." value={itemForm.description} onChange={e => setItemForm(f => ({ ...f, description: e.target.value }))} rows={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddItem(false)}>Annuler</Button>
|
||||||
|
<Button onClick={handleAddItem} disabled={addItemM.isPending} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
{addItemM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog : Modifier une formation */}
|
||||||
|
<Dialog open={!!editItem} onOpenChange={() => setEditItem(null)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier la formation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editItem && (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Intitulé *</Label>
|
||||||
|
<Input value={editItem.intitule} onChange={e => setEditItem(ei => ei ? { ...ei, intitule: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Durée</Label>
|
||||||
|
<Input value={editItem.duree} onChange={e => setEditItem(ei => ei ? { ...ei, duree: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Nb personnes</Label>
|
||||||
|
<Input type="number" value={editItem.nbPersonnes} onChange={e => setEditItem(ei => ei ? { ...ei, nbPersonnes: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Prestataire</Label>
|
||||||
|
<Input value={editItem.prestataire} onChange={e => setEditItem(ei => ei ? { ...ei, prestataire: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Public concerné</Label>
|
||||||
|
<Input value={editItem.publicConcerne} onChange={e => setEditItem(ei => ei ? { ...ei, publicConcerne: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Budget estimé (€)</Label>
|
||||||
|
<Input value={editItem.budgetEstime} onChange={e => setEditItem(ei => ei ? { ...ei, budgetEstime: e.target.value } : null)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Priorité</Label>
|
||||||
|
<Select value={String(editItem.priorite)} onValueChange={v => setEditItem(ei => ei ? { ...ei, priorite: parseInt(v) } : null)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Haute</SelectItem>
|
||||||
|
<SelectItem value="2">Moyenne</SelectItem>
|
||||||
|
<SelectItem value="3">Basse</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea value={editItem.description} onChange={e => setEditItem(ei => ei ? { ...ei, description: e.target.value } : null)} rows={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditItem(null)}>Annuler</Button>
|
||||||
|
<Button onClick={handleUpdateItem} disabled={updateItemM.isPending} className="bg-teal-600 hover:bg-teal-700">
|
||||||
|
{updateItemM.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog : Suggestions catalogue */}
|
||||||
|
<Dialog open={showSuggest} onOpenChange={setShowSuggest}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Lightbulb className="h-5 w-5 text-amber-500" />
|
||||||
|
Formations correspondantes dans le catalogue
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{suggestItem && (
|
||||||
|
<p className="text-sm text-gray-500 mb-3">Recherche pour : <strong>{suggestItem.intitule}</strong></p>
|
||||||
|
)}
|
||||||
|
{!suggestions ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-teal-600" />
|
||||||
|
</div>
|
||||||
|
) : (suggestions.validateesItinova.length === 0 && suggestions.autres.length === 0) ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<Library className="h-10 w-10 mx-auto mb-3 text-gray-200" />
|
||||||
|
<p>Aucune formation correspondante dans le catalogue</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{suggestions.validateesItinova.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-teal-700 mb-2 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Formations validées Itinova
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.validateesItinova.map(f => (
|
||||||
|
<div key={f.id} className="p-3 border border-teal-200 bg-teal-50 rounded-lg flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 text-sm">{f.intitule}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
|
{f.theme && <span>📚 {f.theme}</span>}
|
||||||
|
{f.duree && <span>⏱ {f.duree}</span>}
|
||||||
|
{f.prestataire && <span>🏢 {f.prestataire}</span>}
|
||||||
|
{f.opcoEligible && <Badge className="bg-blue-100 text-blue-700 text-xs">OPCO</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="bg-teal-600 hover:bg-teal-700 flex-shrink-0" onClick={() => handleLinkCatalogue(f.id)}>
|
||||||
|
Lier
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{suggestions.autres.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-600 mb-2">Autres formations du catalogue</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.autres.map(f => (
|
||||||
|
<div key={f.id} className="p-3 border border-gray-200 bg-gray-50 rounded-lg flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 text-sm">{f.intitule}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
|
{f.theme && <span>📚 {f.theme}</span>}
|
||||||
|
{f.duree && <span>⏱ {f.duree}</span>}
|
||||||
|
{f.prestataire && <span>🏢 {f.prestataire}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="flex-shrink-0" onClick={() => handleLinkCatalogue(f.id)}>
|
||||||
|
Lier
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowSuggest(false)}>Fermer</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
2667
drizzle/meta/0020_snapshot.json
Normal file
2667
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
|||||||
"when": 1772187475069,
|
"when": 1772187475069,
|
||||||
"tag": "0019_aberrant_black_tarantula",
|
"tag": "0019_aberrant_black_tarantula",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1779898978661,
|
||||||
|
"tag": "0020_free_the_captain",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -593,3 +593,110 @@ export const historiqueAttestations = mysqlTable("historiqueAttestations", {
|
|||||||
export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect;
|
export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect;
|
||||||
export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert;
|
export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table du catalogue de formations partagé
|
||||||
|
* Géré par l'équipe Itinova, accessible en lecture à tous les établissements
|
||||||
|
*/
|
||||||
|
export const catalogueFormation = mysqlTable("catalogueFormation", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
/** Intitulé de la formation */
|
||||||
|
intitule: varchar("intitule", { length: 255 }).notNull(),
|
||||||
|
/** Thème principal de la formation */
|
||||||
|
theme: varchar("theme", { length: 255 }),
|
||||||
|
/** Mots-clés pour la recherche et la suggestion automatique (séparés par des virgules) */
|
||||||
|
motsCles: text("motsCles"),
|
||||||
|
/** Durée indicative (ex: "2 jours", "14h") */
|
||||||
|
duree: varchar("duree", { length: 100 }),
|
||||||
|
/** Organisme ou prestataire de formation */
|
||||||
|
prestataire: varchar("prestataire", { length: 255 }),
|
||||||
|
/** Description détaillée de la formation */
|
||||||
|
description: text("description"),
|
||||||
|
/** Objectifs pédagogiques */
|
||||||
|
objectifs: text("objectifs"),
|
||||||
|
/** Public concerné */
|
||||||
|
publicConcerne: varchar("publicConcerne", { length: 255 }),
|
||||||
|
/** Formation qualifiée "Validée Itinova" par l'équipe Itinova */
|
||||||
|
validateeItinova: boolean("validateeItinova").default(false).notNull(),
|
||||||
|
/** Formation éligible au financement OPCO */
|
||||||
|
opcoEligible: boolean("opcoEligible").default(false).notNull(),
|
||||||
|
/** Budget OPCO estimé (en euros) */
|
||||||
|
budgetOpco: decimal("budgetOpco", { precision: 10, scale: 2 }),
|
||||||
|
/** Référence du dossier OPCO */
|
||||||
|
referenceOpco: varchar("referenceOpco", { length: 255 }),
|
||||||
|
/** Source de la formation (import CSV, saisie manuelle, organisme externe) */
|
||||||
|
source: varchar("source", { length: 100 }).default("manuel").notNull(),
|
||||||
|
actif: boolean("actif").default(true).notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CatalogueFormation = typeof catalogueFormation.$inferSelect;
|
||||||
|
export type InsertCatalogueFormation = typeof catalogueFormation.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table des plans de formation par établissement et par année
|
||||||
|
*/
|
||||||
|
export const planFormation = mysqlTable("planFormation", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
/** Code de l'établissement concerné */
|
||||||
|
codeEtablissement: varchar("codeEtablissement", { length: 50 }).notNull(),
|
||||||
|
/** Nom de l'établissement (dénormalisé pour affichage) */
|
||||||
|
nomEtablissement: varchar("nomEtablissement", { length: 255 }),
|
||||||
|
/** Année du plan de formation */
|
||||||
|
annee: int("annee").notNull(),
|
||||||
|
/** Statut du plan (brouillon, soumis, valide, rejete) */
|
||||||
|
statut: mysqlEnum("statut", ["brouillon", "soumis", "valide", "rejete"]).default("brouillon").notNull(),
|
||||||
|
/** Notes générales sur le plan */
|
||||||
|
notes: text("notes"),
|
||||||
|
/** Notes de validation par l'équipe Itinova */
|
||||||
|
notesValidation: text("notesValidation"),
|
||||||
|
/** ID de l'utilisateur qui a créé le plan */
|
||||||
|
creePar: int("creePar"),
|
||||||
|
/** ID de l'utilisateur qui a validé le plan */
|
||||||
|
validePar: int("validePar"),
|
||||||
|
/** Date de soumission */
|
||||||
|
dateSoumission: timestamp("dateSoumission"),
|
||||||
|
/** Date de validation */
|
||||||
|
dateValidation: timestamp("dateValidation"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PlanFormation = typeof planFormation.$inferSelect;
|
||||||
|
export type InsertPlanFormation = typeof planFormation.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table des items (lignes) d'un plan de formation
|
||||||
|
* Chaque item représente une formation souhaitée dans le plan
|
||||||
|
*/
|
||||||
|
export const planFormationItem = mysqlTable("planFormationItem", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
/** ID du plan de formation parent */
|
||||||
|
planId: int("planId").notNull(),
|
||||||
|
/** Intitulé de la formation souhaitée */
|
||||||
|
intitule: varchar("intitule", { length: 255 }).notNull(),
|
||||||
|
/** Durée souhaitée */
|
||||||
|
duree: varchar("duree", { length: 100 }),
|
||||||
|
/** Prestataire souhaité */
|
||||||
|
prestataire: varchar("prestataire", { length: 255 }),
|
||||||
|
/** Public concerné par cette formation */
|
||||||
|
publicConcerne: varchar("publicConcerne", { length: 255 }),
|
||||||
|
/** Nombre de personnes à former */
|
||||||
|
nbPersonnes: int("nbPersonnes"),
|
||||||
|
/** Description ou contexte de la demande */
|
||||||
|
description: text("description"),
|
||||||
|
/** Statut de l'item (en_attente, valide, refuse, suggere) */
|
||||||
|
statut: mysqlEnum("statut", ["en_attente", "valide", "refuse", "suggere"]).default("en_attente").notNull(),
|
||||||
|
/** ID de la formation du catalogue associée (suggestion ou validation) */
|
||||||
|
catalogueFormationId: int("catalogueFormationId"),
|
||||||
|
/** Budget estimé pour cet item */
|
||||||
|
budgetEstime: decimal("budgetEstime", { precision: 10, scale: 2 }),
|
||||||
|
/** Priorité (1 = haute, 2 = moyenne, 3 = basse) */
|
||||||
|
priorite: int("priorite").default(2),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PlanFormationItem = typeof planFormationItem.$inferSelect;
|
||||||
|
export type InsertPlanFormationItem = typeof planFormationItem.$inferInsert;
|
||||||
|
|||||||
175
server/__tests__/planFormation.test.ts
Normal file
175
server/__tests__/planFormation.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Tests unitaires pour les routers catalogue et planFormation
|
||||||
|
* Ces tests vérifient la logique de scoring des suggestions et les validations de base.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// ─── Tests logique de scoring des suggestions ─────────────────────────────────
|
||||||
|
|
||||||
|
describe("Catalogue - logique de suggestion", () => {
|
||||||
|
// Reproduire la logique de scoring du router catalogue
|
||||||
|
function scoreFormation(
|
||||||
|
f: { intitule: string; theme?: string | null; motsCles?: string | null; description?: string | null; validateeItinova: boolean },
|
||||||
|
searchTerms: string[]
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
const haystack = [
|
||||||
|
f.intitule,
|
||||||
|
f.theme ?? "",
|
||||||
|
f.motsCles ?? "",
|
||||||
|
f.description ?? "",
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
if (haystack.includes(term)) score += 2;
|
||||||
|
if (term.length >= 4) {
|
||||||
|
const words = haystack.split(/\s+/);
|
||||||
|
for (const word of words) {
|
||||||
|
if (word.startsWith(term.substring(0, 4))) score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (f.validateeItinova) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("doit donner un score plus élevé aux formations validées Itinova", () => {
|
||||||
|
const f1 = { intitule: "Gestes et postures", theme: "Sécurité", motsCles: null, description: null, validateeItinova: true };
|
||||||
|
const f2 = { intitule: "Gestes et postures", theme: "Sécurité", motsCles: null, description: null, validateeItinova: false };
|
||||||
|
const terms = ["gestes"];
|
||||||
|
expect(scoreFormation(f1, terms)).toBeGreaterThan(scoreFormation(f2, terms));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit retourner 0 pour une formation sans correspondance", () => {
|
||||||
|
const f = { intitule: "Formation cuisine", theme: "Alimentation", motsCles: null, description: null, validateeItinova: false };
|
||||||
|
const terms = ["sécurité", "incendie"];
|
||||||
|
expect(scoreFormation(f, terms)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit scorer les correspondances partielles (4+ caractères)", () => {
|
||||||
|
const f = { intitule: "Prévention des risques", theme: null, motsCles: null, description: null, validateeItinova: false };
|
||||||
|
const terms = ["préven"];
|
||||||
|
const score = scoreFormation(f, terms);
|
||||||
|
expect(score).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit scorer les correspondances dans les mots-clés", () => {
|
||||||
|
const f = { intitule: "Formation X", theme: null, motsCles: "manutention, ergonomie, postures", description: null, validateeItinova: false };
|
||||||
|
const terms = ["manutention"];
|
||||||
|
expect(scoreFormation(f, terms)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit trier par score décroissant", () => {
|
||||||
|
const formations = [
|
||||||
|
{ intitule: "Sécurité incendie", theme: "Sécurité", motsCles: "incendie, évacuation", description: null, validateeItinova: false },
|
||||||
|
{ intitule: "Gestes et postures", theme: "Sécurité", motsCles: null, description: null, validateeItinova: true },
|
||||||
|
{ intitule: "Cuisine équilibrée", theme: "Alimentation", motsCles: null, description: null, validateeItinova: false },
|
||||||
|
];
|
||||||
|
const terms = ["sécurité"];
|
||||||
|
const scored = formations
|
||||||
|
.map(f => ({ ...f, _score: scoreFormation(f, terms) }))
|
||||||
|
.filter(f => f._score > 0)
|
||||||
|
.sort((a, b) => b._score - a._score);
|
||||||
|
|
||||||
|
expect(scored.length).toBe(2);
|
||||||
|
expect(scored[0]._score).toBeGreaterThanOrEqual(scored[1]._score);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tests validation des données de plan ────────────────────────────────────
|
||||||
|
|
||||||
|
describe("PlanFormation - validation des données", () => {
|
||||||
|
it("doit valider qu'une année est dans une plage raisonnable", () => {
|
||||||
|
const anneeMin = 2020;
|
||||||
|
const anneeMax = 2100;
|
||||||
|
const validAnnees = [2024, 2025, 2026, 2030];
|
||||||
|
const invalidAnnees = [1999, 2101, 0, -1];
|
||||||
|
|
||||||
|
validAnnees.forEach(a => {
|
||||||
|
expect(a >= anneeMin && a <= anneeMax).toBe(true);
|
||||||
|
});
|
||||||
|
invalidAnnees.forEach(a => {
|
||||||
|
expect(a >= anneeMin && a <= anneeMax).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit valider les statuts possibles d'un plan", () => {
|
||||||
|
const statutsValides = ["brouillon", "soumis", "valide", "rejete"];
|
||||||
|
expect(statutsValides).toContain("brouillon");
|
||||||
|
expect(statutsValides).toContain("soumis");
|
||||||
|
expect(statutsValides).not.toContain("archive");
|
||||||
|
expect(statutsValides).not.toContain("en_cours");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit valider les statuts possibles d'un item", () => {
|
||||||
|
const statutsValides = ["en_attente", "valide", "refuse", "suggere"];
|
||||||
|
expect(statutsValides).toContain("en_attente");
|
||||||
|
expect(statutsValides).toContain("suggere");
|
||||||
|
expect(statutsValides).not.toContain("brouillon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit valider les priorités (1=haute, 2=moyenne, 3=basse)", () => {
|
||||||
|
const prioritesValides = [1, 2, 3];
|
||||||
|
expect(prioritesValides).toContain(1);
|
||||||
|
expect(prioritesValides).toContain(2);
|
||||||
|
expect(prioritesValides).toContain(3);
|
||||||
|
expect(prioritesValides).not.toContain(0);
|
||||||
|
expect(prioritesValides).not.toContain(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tests parsing CSV ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CatalogueFormation - parsing CSV", () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("doit parser une ligne CSV simple avec virgule", () => {
|
||||||
|
const line = "Formation A,Sécurité,2 jours,Organisme X";
|
||||||
|
const result = parseCsvLine(line);
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result[0]).toBe("Formation A");
|
||||||
|
expect(result[1]).toBe("Sécurité");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit parser une ligne CSV avec point-virgule", () => {
|
||||||
|
const line = "Formation B;Soins;1 jour;Organisme Y";
|
||||||
|
const result = parseCsvLine(line);
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result[0]).toBe("Formation B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit gérer les valeurs entre guillemets", () => {
|
||||||
|
const line = '"Formation, avec virgule",Thème,3 jours';
|
||||||
|
const result = parseCsvLine(line);
|
||||||
|
expect(result[0]).toBe("Formation, avec virgule");
|
||||||
|
expect(result[1]).toBe("Thème");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doit détecter l'éligibilité OPCO", () => {
|
||||||
|
const opcoValues = ["oui", "true", "1", "x", "OUI", "TRUE"];
|
||||||
|
const nonOpcoValues = ["non", "false", "0", "", "no"];
|
||||||
|
opcoValues.forEach(v => {
|
||||||
|
expect(["oui", "true", "1", "x"].includes(v.toLowerCase())).toBe(true);
|
||||||
|
});
|
||||||
|
nonOpcoValues.forEach(v => {
|
||||||
|
expect(["oui", "true", "1", "x"].includes(v.toLowerCase())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,8 @@ import { parseExcelFile, validateImportData, importToDatabase } from "./importEx
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import { catalogueRouter } from "./routers/catalogue";
|
||||||
|
import { planFormationRouter } from "./routers/planFormation";
|
||||||
|
|
||||||
// Procédure admin uniquement
|
// Procédure admin uniquement
|
||||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
@@ -3135,6 +3137,7 @@ export const appRouter = router({
|
|||||||
return await fail2banDb.countRecentBans(input.hours);
|
return await fail2banDb.countRecentBans(input.hours);
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
catalogue: catalogueRouter,
|
||||||
|
planFormation: planFormationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
308
server/routers/catalogue.ts
Normal file
308
server/routers/catalogue.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { protectedProcedure, router } from "../_core/trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import { catalogueFormation } from "../../drizzle/schema";
|
||||||
|
import { eq, like, or, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isAdmin(role: string) {
|
||||||
|
return role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const catalogueRouter = router({
|
||||||
|
/** Lister toutes les formations du catalogue (accessible à tous les utilisateurs connectés) */
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
validateeItinova: z.boolean().optional(),
|
||||||
|
opcoEligible: z.boolean().optional(),
|
||||||
|
}).optional()
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const rows = await db.select().from(catalogueFormation);
|
||||||
|
|
||||||
|
let filtered = rows.filter(r => r.actif);
|
||||||
|
|
||||||
|
if (input?.search) {
|
||||||
|
const s = input.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(r =>
|
||||||
|
r.intitule.toLowerCase().includes(s) ||
|
||||||
|
(r.theme ?? "").toLowerCase().includes(s) ||
|
||||||
|
(r.motsCles ?? "").toLowerCase().includes(s) ||
|
||||||
|
(r.prestataire ?? "").toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input?.theme) {
|
||||||
|
filtered = filtered.filter(r => r.theme === input.theme);
|
||||||
|
}
|
||||||
|
if (input?.validateeItinova !== undefined) {
|
||||||
|
filtered = filtered.filter(r => r.validateeItinova === input.validateeItinova);
|
||||||
|
}
|
||||||
|
if (input?.opcoEligible !== undefined) {
|
||||||
|
filtered = filtered.filter(r => r.opcoEligible === input.opcoEligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Obtenir une formation par ID */
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
const rows = await db.select().from(catalogueFormation).where(eq(catalogueFormation.id, input.id));
|
||||||
|
if (!rows[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Formation introuvable" });
|
||||||
|
return rows[0];
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Créer une formation dans le catalogue (admin uniquement) */
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
intitule: z.string().min(1),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
motsCles: z.string().optional(),
|
||||||
|
duree: z.string().optional(),
|
||||||
|
prestataire: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
objectifs: z.string().optional(),
|
||||||
|
publicConcerne: z.string().optional(),
|
||||||
|
validateeItinova: z.boolean().optional(),
|
||||||
|
opcoEligible: z.boolean().optional(),
|
||||||
|
budgetOpco: z.string().optional(),
|
||||||
|
referenceOpco: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
await db.insert(catalogueFormation).values({
|
||||||
|
intitule: input.intitule,
|
||||||
|
theme: input.theme ?? null,
|
||||||
|
motsCles: input.motsCles ?? null,
|
||||||
|
duree: input.duree ?? null,
|
||||||
|
prestataire: input.prestataire ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
objectifs: input.objectifs ?? null,
|
||||||
|
publicConcerne: input.publicConcerne ?? null,
|
||||||
|
validateeItinova: input.validateeItinova ?? false,
|
||||||
|
opcoEligible: input.opcoEligible ?? false,
|
||||||
|
budgetOpco: input.budgetOpco ? input.budgetOpco : null,
|
||||||
|
referenceOpco: input.referenceOpco ?? null,
|
||||||
|
source: input.source ?? "manuel",
|
||||||
|
actif: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Mettre à jour une formation du catalogue (admin uniquement) */
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
intitule: z.string().min(1).optional(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
motsCles: z.string().optional(),
|
||||||
|
duree: z.string().optional(),
|
||||||
|
prestataire: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
objectifs: z.string().optional(),
|
||||||
|
publicConcerne: z.string().optional(),
|
||||||
|
validateeItinova: z.boolean().optional(),
|
||||||
|
opcoEligible: z.boolean().optional(),
|
||||||
|
budgetOpco: z.string().optional(),
|
||||||
|
referenceOpco: z.string().optional(),
|
||||||
|
actif: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const { id, ...rest } = input;
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (rest.intitule !== undefined) updateData.intitule = rest.intitule;
|
||||||
|
if (rest.theme !== undefined) updateData.theme = rest.theme;
|
||||||
|
if (rest.motsCles !== undefined) updateData.motsCles = rest.motsCles;
|
||||||
|
if (rest.duree !== undefined) updateData.duree = rest.duree;
|
||||||
|
if (rest.prestataire !== undefined) updateData.prestataire = rest.prestataire;
|
||||||
|
if (rest.description !== undefined) updateData.description = rest.description;
|
||||||
|
if (rest.objectifs !== undefined) updateData.objectifs = rest.objectifs;
|
||||||
|
if (rest.publicConcerne !== undefined) updateData.publicConcerne = rest.publicConcerne;
|
||||||
|
if (rest.validateeItinova !== undefined) updateData.validateeItinova = rest.validateeItinova;
|
||||||
|
if (rest.opcoEligible !== undefined) updateData.opcoEligible = rest.opcoEligible;
|
||||||
|
if (rest.budgetOpco !== undefined) updateData.budgetOpco = rest.budgetOpco;
|
||||||
|
if (rest.referenceOpco !== undefined) updateData.referenceOpco = rest.referenceOpco;
|
||||||
|
if (rest.actif !== undefined) updateData.actif = rest.actif;
|
||||||
|
|
||||||
|
await db.update(catalogueFormation).set(updateData).where(eq(catalogueFormation.id, id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Supprimer une formation du catalogue (admin uniquement) */
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
await db.delete(catalogueFormation).where(eq(catalogueFormation.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Qualifier une formation comme "Validée Itinova" (admin uniquement) */
|
||||||
|
toggleValideeItinova: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number(), valeur: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
await db.update(catalogueFormation)
|
||||||
|
.set({ validateeItinova: input.valeur })
|
||||||
|
.where(eq(catalogueFormation.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Importer des formations depuis un CSV/Excel (admin uniquement) */
|
||||||
|
importBulk: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
formations: z.array(
|
||||||
|
z.object({
|
||||||
|
intitule: z.string().min(1),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
motsCles: z.string().optional(),
|
||||||
|
duree: z.string().optional(),
|
||||||
|
prestataire: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
objectifs: z.string().optional(),
|
||||||
|
publicConcerne: z.string().optional(),
|
||||||
|
opcoEligible: z.boolean().optional(),
|
||||||
|
budgetOpco: z.string().optional(),
|
||||||
|
referenceOpco: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
source: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const f of input.formations) {
|
||||||
|
await db.insert(catalogueFormation).values({
|
||||||
|
intitule: f.intitule,
|
||||||
|
theme: f.theme ?? null,
|
||||||
|
motsCles: f.motsCles ?? null,
|
||||||
|
duree: f.duree ?? null,
|
||||||
|
prestataire: f.prestataire ?? null,
|
||||||
|
description: f.description ?? null,
|
||||||
|
objectifs: f.objectifs ?? null,
|
||||||
|
publicConcerne: f.publicConcerne ?? null,
|
||||||
|
validateeItinova: false,
|
||||||
|
opcoEligible: f.opcoEligible ?? false,
|
||||||
|
budgetOpco: f.budgetOpco ?? null,
|
||||||
|
referenceOpco: f.referenceOpco ?? null,
|
||||||
|
source: input.source ?? "import",
|
||||||
|
actif: true,
|
||||||
|
});
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, inserted };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Suggestion automatique : trouver les formations correspondant à un intitulé/thème */
|
||||||
|
suggest: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
intitule: z.string(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
motsCles: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const allFormations = await db.select().from(catalogueFormation).where(eq(catalogueFormation.actif, true));
|
||||||
|
|
||||||
|
const searchTerms = [
|
||||||
|
input.intitule.toLowerCase(),
|
||||||
|
...(input.theme ? [input.theme.toLowerCase()] : []),
|
||||||
|
...(input.motsCles ? input.motsCles.toLowerCase().split(",").map(k => k.trim()) : []),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
function scoreFormation(f: typeof allFormations[0]): number {
|
||||||
|
let score = 0;
|
||||||
|
const haystack = [
|
||||||
|
f.intitule,
|
||||||
|
f.theme ?? "",
|
||||||
|
f.motsCles ?? "",
|
||||||
|
f.description ?? "",
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
if (haystack.includes(term)) score += 2;
|
||||||
|
// Correspondance partielle (au moins 4 caractères)
|
||||||
|
if (term.length >= 4) {
|
||||||
|
const words = haystack.split(/\s+/);
|
||||||
|
for (const word of words) {
|
||||||
|
if (word.startsWith(term.substring(0, 4))) score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bonus si validée Itinova
|
||||||
|
if (f.validateeItinova) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored = allFormations
|
||||||
|
.map(f => ({ ...f, _score: scoreFormation(f) }))
|
||||||
|
.filter(f => f._score > 0)
|
||||||
|
.sort((a, b) => b._score - a._score)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Séparer validées Itinova des autres
|
||||||
|
const validateesItinova = scored.filter(f => f.validateeItinova);
|
||||||
|
const autres = scored.filter(f => !f.validateeItinova);
|
||||||
|
|
||||||
|
return { validateesItinova, autres };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Lister les thèmes distincts du catalogue */
|
||||||
|
themes: protectedProcedure.query(async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
const rows = await db.select({ theme: catalogueFormation.theme }).from(catalogueFormation);
|
||||||
|
const themes = [...new Set(rows.map(r => r.theme).filter(Boolean))] as string[];
|
||||||
|
return themes.sort();
|
||||||
|
}),
|
||||||
|
});
|
||||||
306
server/routers/planFormation.ts
Normal file
306
server/routers/planFormation.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { protectedProcedure, router } from "../_core/trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import { planFormation, planFormationItem, catalogueFormation } from "../../drizzle/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isAdmin(role: string) {
|
||||||
|
return role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Router Plans de formation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const planFormationRouter = router({
|
||||||
|
|
||||||
|
/** Lister les plans de formation
|
||||||
|
* - Admin : voit tous les plans
|
||||||
|
* - User : voit uniquement les plans de son établissement (via codeEtablissement)
|
||||||
|
*/
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
codeEtablissement: z.string().optional(),
|
||||||
|
annee: z.number().optional(),
|
||||||
|
statut: z.enum(["brouillon", "soumis", "valide", "rejete"]).optional(),
|
||||||
|
}).optional()
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
let rows = await db.select().from(planFormation);
|
||||||
|
|
||||||
|
if (input?.codeEtablissement) {
|
||||||
|
rows = rows.filter(r => r.codeEtablissement === input.codeEtablissement);
|
||||||
|
}
|
||||||
|
if (input?.annee) {
|
||||||
|
rows = rows.filter(r => r.annee === input.annee);
|
||||||
|
}
|
||||||
|
if (input?.statut) {
|
||||||
|
rows = rows.filter(r => r.statut === input.statut);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => b.annee - a.annee);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Obtenir un plan par ID avec ses items */
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const plans = await db.select().from(planFormation).where(eq(planFormation.id, input.id));
|
||||||
|
if (!plans[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Plan introuvable" });
|
||||||
|
|
||||||
|
const items = await db.select().from(planFormationItem).where(eq(planFormationItem.planId, input.id));
|
||||||
|
|
||||||
|
// Récupérer les formations du catalogue associées
|
||||||
|
const catalogueIds = items
|
||||||
|
.map(i => i.catalogueFormationId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
let catalogueItems: typeof catalogueFormation.$inferSelect[] = [];
|
||||||
|
if (catalogueIds.length > 0) {
|
||||||
|
catalogueItems = await db.select().from(catalogueFormation);
|
||||||
|
catalogueItems = catalogueItems.filter(c => catalogueIds.includes(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsWithCatalogue = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
catalogueFormation: item.catalogueFormationId
|
||||||
|
? catalogueItems.find(c => c.id === item.catalogueFormationId) ?? null
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ...plans[0], items: itemsWithCatalogue };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Créer un plan de formation */
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
codeEtablissement: z.string().min(1),
|
||||||
|
nomEtablissement: z.string().optional(),
|
||||||
|
annee: z.number().min(2020).max(2100),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
await db.insert(planFormation).values({
|
||||||
|
codeEtablissement: input.codeEtablissement,
|
||||||
|
nomEtablissement: input.nomEtablissement ?? null,
|
||||||
|
annee: input.annee,
|
||||||
|
statut: "brouillon",
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
creePar: ctx.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Mettre à jour un plan de formation */
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
codeEtablissement: z.string().optional(),
|
||||||
|
nomEtablissement: z.string().optional(),
|
||||||
|
annee: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
notesValidation: z.string().optional(),
|
||||||
|
statut: z.enum(["brouillon", "soumis", "valide", "rejete"]).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const { id, ...rest } = input;
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (rest.codeEtablissement !== undefined) updateData.codeEtablissement = rest.codeEtablissement;
|
||||||
|
if (rest.nomEtablissement !== undefined) updateData.nomEtablissement = rest.nomEtablissement;
|
||||||
|
if (rest.annee !== undefined) updateData.annee = rest.annee;
|
||||||
|
if (rest.notes !== undefined) updateData.notes = rest.notes;
|
||||||
|
if (rest.notesValidation !== undefined) updateData.notesValidation = rest.notesValidation;
|
||||||
|
if (rest.statut !== undefined) {
|
||||||
|
updateData.statut = rest.statut;
|
||||||
|
if (rest.statut === "soumis") {
|
||||||
|
updateData.dateSoumission = new Date();
|
||||||
|
}
|
||||||
|
if ((rest.statut === "valide" || rest.statut === "rejete") && isAdmin(ctx.user.role)) {
|
||||||
|
updateData.dateValidation = new Date();
|
||||||
|
updateData.validePar = ctx.user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(planFormation).set(updateData).where(eq(planFormation.id, id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Soumettre un plan pour validation */
|
||||||
|
soumettre: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
await db.update(planFormation).set({
|
||||||
|
statut: "soumis",
|
||||||
|
dateSoumission: new Date(),
|
||||||
|
}).where(eq(planFormation.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Valider ou rejeter un plan (admin uniquement) */
|
||||||
|
valider: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
decision: z.enum(["valide", "rejete"]),
|
||||||
|
notesValidation: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!isAdmin(ctx.user.role)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" });
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
await db.update(planFormation).set({
|
||||||
|
statut: input.decision,
|
||||||
|
notesValidation: input.notesValidation ?? null,
|
||||||
|
dateValidation: new Date(),
|
||||||
|
validePar: ctx.user.id,
|
||||||
|
}).where(eq(planFormation.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Supprimer un plan (admin ou créateur si brouillon) */
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const plans = await db.select().from(planFormation).where(eq(planFormation.id, input.id));
|
||||||
|
if (!plans[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Plan introuvable" });
|
||||||
|
|
||||||
|
const plan = plans[0];
|
||||||
|
if (!isAdmin(ctx.user.role) && (plan.statut !== "brouillon" || plan.creePar !== ctx.user.id)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez supprimer que vos propres brouillons" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer les items d'abord
|
||||||
|
await db.delete(planFormationItem).where(eq(planFormationItem.planId, input.id));
|
||||||
|
await db.delete(planFormation).where(eq(planFormation.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Items du plan ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ajouter un item au plan */
|
||||||
|
addItem: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
planId: z.number(),
|
||||||
|
intitule: z.string().min(1),
|
||||||
|
duree: z.string().optional(),
|
||||||
|
prestataire: z.string().optional(),
|
||||||
|
publicConcerne: z.string().optional(),
|
||||||
|
nbPersonnes: z.number().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
budgetEstime: z.string().optional(),
|
||||||
|
priorite: z.number().min(1).max(3).optional(),
|
||||||
|
catalogueFormationId: z.number().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
await db.insert(planFormationItem).values({
|
||||||
|
planId: input.planId,
|
||||||
|
intitule: input.intitule,
|
||||||
|
duree: input.duree ?? null,
|
||||||
|
prestataire: input.prestataire ?? null,
|
||||||
|
publicConcerne: input.publicConcerne ?? null,
|
||||||
|
nbPersonnes: input.nbPersonnes ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
statut: "en_attente",
|
||||||
|
budgetEstime: input.budgetEstime ?? null,
|
||||||
|
priorite: input.priorite ?? 2,
|
||||||
|
catalogueFormationId: input.catalogueFormationId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Mettre à jour un item */
|
||||||
|
updateItem: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
intitule: z.string().optional(),
|
||||||
|
duree: z.string().optional(),
|
||||||
|
prestataire: z.string().optional(),
|
||||||
|
publicConcerne: z.string().optional(),
|
||||||
|
nbPersonnes: z.number().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
statut: z.enum(["en_attente", "valide", "refuse", "suggere"]).optional(),
|
||||||
|
budgetEstime: z.string().optional(),
|
||||||
|
priorite: z.number().min(1).max(3).optional(),
|
||||||
|
catalogueFormationId: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
|
||||||
|
const { id, ...rest } = input;
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (rest.intitule !== undefined) updateData.intitule = rest.intitule;
|
||||||
|
if (rest.duree !== undefined) updateData.duree = rest.duree;
|
||||||
|
if (rest.prestataire !== undefined) updateData.prestataire = rest.prestataire;
|
||||||
|
if (rest.publicConcerne !== undefined) updateData.publicConcerne = rest.publicConcerne;
|
||||||
|
if (rest.nbPersonnes !== undefined) updateData.nbPersonnes = rest.nbPersonnes;
|
||||||
|
if (rest.description !== undefined) updateData.description = rest.description;
|
||||||
|
if (rest.statut !== undefined) updateData.statut = rest.statut;
|
||||||
|
if (rest.budgetEstime !== undefined) updateData.budgetEstime = rest.budgetEstime;
|
||||||
|
if (rest.priorite !== undefined) updateData.priorite = rest.priorite;
|
||||||
|
if (rest.catalogueFormationId !== undefined) updateData.catalogueFormationId = rest.catalogueFormationId;
|
||||||
|
|
||||||
|
await db.update(planFormationItem).set(updateData).where(eq(planFormationItem.id, id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Supprimer un item */
|
||||||
|
deleteItem: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
await db.delete(planFormationItem).where(eq(planFormationItem.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Lister les items d'un plan */
|
||||||
|
listItems: protectedProcedure
|
||||||
|
.input(z.object({ planId: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" });
|
||||||
|
const items = await db.select().from(planFormationItem).where(eq(planFormationItem.planId, input.planId));
|
||||||
|
return items;
|
||||||
|
}),
|
||||||
|
});
|
||||||
45
todo.md
45
todo.md
@@ -1724,3 +1724,48 @@
|
|||||||
- [x] Résolution 500x160px pour équilibre qualité/taille (< 1 Mo par PDF)
|
- [x] Résolution 500x160px pour équilibre qualité/taille (< 1 Mo par PDF)
|
||||||
- [x] Tester sur TEST
|
- [x] Tester sur TEST
|
||||||
- [x] Déployer sur PROD
|
- [x] Déployer sur PROD
|
||||||
|
|
||||||
|
## Fonctionnalité - Gestion des plans de formations
|
||||||
|
|
||||||
|
### Schéma DB
|
||||||
|
- [ ] Ajouter la table catalogueFormation (intitulé, thème, mots-clés, durée, prestataire, description, validateeItinova, opcoEligible, budgetOpco, referenceOpco, source)
|
||||||
|
- [ ] Ajouter la table planFormation (etablissementId, annee, statut, notes)
|
||||||
|
- [ ] Ajouter la table planFormationItem (planId, intitule, duree, prestataire, publicConcerne, description, statut, catalogueFormationId optionnel)
|
||||||
|
- [ ] Migrer le schéma avec pnpm db:push
|
||||||
|
|
||||||
|
### Backend tRPC
|
||||||
|
- [ ] Procédures catalogue : list, create, update, delete, importCSV, importExcel
|
||||||
|
- [ ] Procédures planFormation : list, create, update, delete, valider
|
||||||
|
- [ ] Procédures planFormationItem : list, create, update, delete
|
||||||
|
- [ ] Logique de suggestion automatique (formations "validée Itinova" par thème/mot-clé, sinon catalogue général)
|
||||||
|
|
||||||
|
### Frontend - Menu & Navigation
|
||||||
|
- [x] Ajouter la section "Gestion des plans de formations" dans DashboardLayout.tsx
|
||||||
|
- [x] Ajouter les routes dans App.tsx
|
||||||
|
|
||||||
|
### Frontend - Page Plans de formation
|
||||||
|
- [x] Créer la page PlanFormation.tsx (liste des plans par établissement/année)
|
||||||
|
- [x] Formulaire de création/édition d'un plan (établissement + année)
|
||||||
|
- [x] Gestion des items du plan (intitulé, durée, prestataire, public concerné, description)
|
||||||
|
- [x] Statut du plan (brouillon, soumis, validé)
|
||||||
|
- [x] Vue détail d'un plan avec ses items
|
||||||
|
- [x] Bouton "Soumettre pour validation" (rôle établissement)
|
||||||
|
- [x] Bouton "Valider le plan" avec suggestion automatique (rôle admin/Itinova)
|
||||||
|
|
||||||
|
### Frontend - Page Catalogue de formation
|
||||||
|
- [x] Créer la page CatalogueFormation.tsx (liste des formations du catalogue)
|
||||||
|
- [x] Formulaire d'ajout/édition d'une formation du catalogue
|
||||||
|
- [x] Badge "Validée Itinova" (qualification par admin)
|
||||||
|
- [x] Champs OPCO (éligibilité, budget, référence)
|
||||||
|
- [x] Import CSV/Excel de formations
|
||||||
|
- [x] Filtres par thème, validée Itinova, OPCO
|
||||||
|
|
||||||
|
### Frontend - Suggestion automatique
|
||||||
|
- [x] Modal de suggestion lors de la validation d'un plan
|
||||||
|
- [x] Afficher en priorité les formations "validée Itinova" correspondant par thème/mot-clé
|
||||||
|
- [x] Sinon afficher les formations du catalogue général
|
||||||
|
- [x] Permettre d'associer une formation du catalogue à un item du plan
|
||||||
|
|
||||||
|
### Déploiement
|
||||||
|
- [ ] Tester sur TEST
|
||||||
|
- [ ] Déployer sur PROD
|
||||||
|
|||||||
Reference in New Issue
Block a user