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:
Manus
2026-05-27 16:39:18 +00:00
parent 54159fd872
commit 6952ae6907
14 changed files with 5295 additions and 2 deletions

308
server/routers/catalogue.ts Normal file
View 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();
}),
});

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