- 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)
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
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;
|
|
}),
|
|
});
|