diff --git a/.manus/db/db-query-1767880187887.json b/.manus/db/db-query-1767880187887.json new file mode 100644 index 0000000..8164668 --- /dev/null +++ b/.manus/db/db-query-1767880187887.json @@ -0,0 +1,26 @@ +{ + "query": "SELECT rd.*, r.nom as rappel_nom, df.dateDebut \nFROM rappelDates rd \nJOIN rappels r ON rd.rappelId = r.id \nJOIN datesFormation df ON rd.dateFormationId = df.id \nWHERE r.nom = 'Rappel 3';", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT rd.*, r.nom as rappel_nom, df.dateDebut \nFROM rappelDates rd \nJOIN rappels r ON rd.rappelId = r.id \nJOIN datesFormation df ON rd.dateFormationId = df.id \nWHERE r.nom = 'Rappel 3';", + "rows": [ + { + "id": "1", + "rappelId": "60003", + "dateFormationId": "900001", + "createdAt": "2026-01-08 13:48:36", + "rappel_nom": "Rappel 3", + "dateDebut": "2025-11-20 12:48:00" + }, + { + "id": "2", + "rappelId": "60003", + "dateFormationId": "480011", + "createdAt": "2026-01-08 13:48:36", + "rappel_nom": "Rappel 3", + "dateDebut": "2026-03-03 14:51:00" + } + ], + "messages": [], + "stdout": "id\trappelId\tdateFormationId\tcreatedAt\trappel_nom\tdateDebut\n1\t60003\t900001\t2026-01-08 13:48:36\tRappel 3\t2025-11-20 12:48:00\n2\t60003\t480011\t2026-01-08 13:48:36\tRappel 3\t2026-03-03 14:51:00\n", + "stderr": "", + "execution_time_ms": 160 +} \ No newline at end of file diff --git a/client/src/components/DateFormationMultiSelect.tsx b/client/src/components/DateFormationMultiSelect.tsx new file mode 100644 index 0000000..e3c4465 --- /dev/null +++ b/client/src/components/DateFormationMultiSelect.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronDown, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface DateFormation { + id: number; + date: string; + heureDebut: string; + heureFin: string; +} + +interface Sequence { + id: number; + nom: string; + formationNom: string; + dates: DateFormation[]; +} + +interface DateFormationMultiSelectProps { + sequences: Sequence[]; + selectedDateIds: number[]; + onSelectionChange: (dateIds: number[]) => void; +} + +export function DateFormationMultiSelect({ + sequences, + selectedDateIds, + onSelectionChange, +}: DateFormationMultiSelectProps) { + const [open, setOpen] = useState(false); + const [expandedSequences, setExpandedSequences] = useState>(new Set()); + + const toggleSequence = (sequenceId: number) => { + const newExpanded = new Set(expandedSequences); + if (newExpanded.has(sequenceId)) { + newExpanded.delete(sequenceId); + } else { + newExpanded.add(sequenceId); + } + setExpandedSequences(newExpanded); + }; + + const toggleDate = (dateId: number) => { + const newSelection = selectedDateIds.includes(dateId) + ? selectedDateIds.filter((id) => id !== dateId) + : [...selectedDateIds, dateId]; + onSelectionChange(newSelection); + }; + + const toggleAllDatesInSequence = (sequence: Sequence) => { + const sequenceDateIds = sequence.dates.map((d) => d.id); + const allSelected = sequenceDateIds.every((id) => selectedDateIds.includes(id)); + + if (allSelected) { + // Désélectionner toutes les dates de cette séquence + onSelectionChange(selectedDateIds.filter((id) => !sequenceDateIds.includes(id))); + } else { + // Sélectionner toutes les dates de cette séquence + const newSelection = [...new Set([...selectedDateIds, ...sequenceDateIds])]; + onSelectionChange(newSelection); + } + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + }; + + const getSelectedCount = () => { + return selectedDateIds.length; + }; + + const getSelectedLabel = () => { + const count = getSelectedCount(); + if (count === 0) { + return "Toutes les dates (par défaut)"; + } + return `${count} date(s) sélectionnée(s)`; + }; + + return ( + + + + + +
+ {sequences.length === 0 ? ( +
+ Aucune séquence disponible +
+ ) : ( +
+ {sequences.map((sequence) => { + const sequenceDateIds = sequence.dates.map((d) => d.id); + const allSelected = sequenceDateIds.length > 0 && + sequenceDateIds.every((id) => selectedDateIds.includes(id)); + const someSelected = sequenceDateIds.some((id) => selectedDateIds.includes(id)); + const isExpanded = expandedSequences.has(sequence.id); + + return ( +
+ {/* En-tête de séquence */} +
+ + +
+ + {/* Liste des dates */} + {isExpanded && ( +
+ {sequence.dates.map((date) => { + const isSelected = selectedDateIds.includes(date.id); + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ )} +
+
+ Si aucune date n'est sélectionnée, le rappel s'appliquera à toutes les dates +
+
+
+ ); +} diff --git a/client/src/pages/AdminRappels.tsx b/client/src/pages/AdminRappels.tsx index 4209612..8d4baea 100644 --- a/client/src/pages/AdminRappels.tsx +++ b/client/src/pages/AdminRappels.tsx @@ -25,7 +25,7 @@ import { trpc } from "@/lib/trpc"; import { Bell, Edit, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { FileUpload } from "@/components/FileUpload"; -import { SequenceMultiSelect } from "@/components/SequenceMultiSelect"; +import { DateFormationMultiSelect } from "@/components/DateFormationMultiSelect"; // Types de templates disponibles pour les rappels const TEMPLATE_TYPES = [ @@ -48,7 +48,7 @@ export default function AdminRappels() { joursAvant: 7, heureEnvoi: "09:00", actif: true, - sequenceIds: [] as number[], + dateFormationIds: [] as number[], fichier: null as { nomFichier: string; urlFichier: string; @@ -114,7 +114,7 @@ export default function AdminRappels() { joursAvant: 7, heureEnvoi: "09:00", actif: true, - sequenceIds: [], + dateFormationIds: [], }); }; @@ -135,7 +135,7 @@ export default function AdminRappels() { joursAvant: rappel.joursAvant, heureEnvoi: rappel.heureEnvoi, actif: rappel.actif, - sequenceIds: rappel.sequenceIds || [], + dateFormationIds: rappel.dateFormationIds || [], fichier: rappel.nomFichier ? { nomFichier: rappel.nomFichier, urlFichier: rappel.urlFichier, @@ -382,20 +382,22 @@ export default function AdminRappels() {
- - Dates de formation concernées (optionnel) + ({ id: seq.id, nom: seq.nom, - formationNom: seq.formation?.nom + formationNom: seq.formation?.nom || '', + dates: seq.dates?.map(d => ({ + id: d.id, + date: d.dateDebut, + heureDebut: d.heureDebut || '', + heureFin: d.heureFin || '', + })) || [], })) || []} - selectedIds={formData.sequenceIds} - onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })} - placeholder="Toutes les séquences (par défaut)" + selectedDateIds={formData.dateFormationIds} + onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })} /> -

- Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences -

- - Dates de formation concernées (optionnel) + ({ id: seq.id, nom: seq.nom, - formationNom: seq.formation?.nom + formationNom: seq.formation?.nom || '', + dates: seq.dates?.map(d => ({ + id: d.id, + date: d.dateDebut, + heureDebut: d.heureDebut || '', + heureFin: d.heureFin || '', + })) || [], })) || []} - selectedIds={formData.sequenceIds} - onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })} - placeholder="Toutes les séquences (par défaut)" + selectedDateIds={formData.dateFormationIds} + onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })} /> -

- Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences -

{ - const sequenceIds = await getRappelSequences(rappel.id); + const dateFormationIds = await getRappelDates(rappel.id); return { ...rappel, - sequenceIds, + dateFormationIds, }; }) ); - return rappelsWithSequences; + return rappelsWithDates; } export async function getRappelById(id: number): Promise { @@ -1037,40 +1037,40 @@ export async function deleteRappel(id: number) { if (!db) throw new Error("Database not available"); // Supprimer d'abord les associations - await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, id)); + await db.delete(rappelDates).where(eq(rappelDates.rappelId, id)); // Puis le rappel await db.delete(rappels).where(eq(rappels.id, id)); } -// ==================== RAPPEL SEQUENCES ==================== +// ==================== RAPPEL DATES ==================== -export async function createRappelSequences(rappelId: number, sequenceIds: number[]) { +export async function createRappelDates(rappelId: number, dateFormationIds: number[]) { const db = await getDb(); if (!db) throw new Error("Database not available"); - if (sequenceIds.length === 0) return; + if (dateFormationIds.length === 0) return; - const values = sequenceIds.map(sequenceId => ({ + const values = dateFormationIds.map(dateFormationId => ({ rappelId, - sequenceId, + dateFormationId, })); - await db.insert(rappelSequences).values(values); + await db.insert(rappelDates).values(values); } -export async function deleteRappelSequences(rappelId: number) { +export async function deleteRappelDates(rappelId: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); - await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, rappelId)); + await db.delete(rappelDates).where(eq(rappelDates.rappelId, rappelId)); } -export async function getRappelSequences(rappelId: number): Promise { +export async function getRappelDates(rappelId: number): Promise { const db = await getDb(); if (!db) return []; - const results = await db.select().from(rappelSequences).where(eq(rappelSequences.rappelId, rappelId)); - return results.map(r => r.sequenceId); + const results = await db.select().from(rappelDates).where(eq(rappelDates.rappelId, rappelId)); + return results.map(r => r.dateFormationId); } export async function updateRappelExecution(id: number) { diff --git a/server/rappelScheduler.ts b/server/rappelScheduler.ts index 6f95057..e1a1869 100644 --- a/server/rappelScheduler.ts +++ b/server/rappelScheduler.ts @@ -39,91 +39,101 @@ export async function processRappelsAutomatiques() { } async function processRappel(rappel: any) { - console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (J-${rappel.joursAvant})`); + console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`); const db = await getDb(); if (!db) return; try { - // Calculer la date cible (aujourd'hui + joursAvant) + // Calculer la date cible selon le timing (pré ou post-formation) const dateCible = new Date(); - dateCible.setDate(dateCible.getDate() + rappel.joursAvant); + if (rappel.timing === 'pre_formation') { + dateCible.setDate(dateCible.getDate() + rappel.joursAvant); + } else { + dateCible.setDate(dateCible.getDate() - rappel.joursAvant); + } dateCible.setHours(0, 0, 0, 0); const dateCibleFin = new Date(dateCible); dateCibleFin.setHours(23, 59, 59, 999); - console.log(`[Rappels] Recherche des séquences débutant le ${dateCible.toLocaleDateString()}`); + console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`); - // Vérifier si ce rappel est associé à des séquences spécifiques - const sequencesAssociees = rappel.sequenceIds || []; - const filtreSequences = sequencesAssociees.length > 0; + // Vérifier si ce rappel est associé à des dates spécifiques + const datesAssociees = rappel.dateFormationIds || []; + const filtreDates = datesAssociees.length > 0; - console.log(`[Rappels] ${filtreSequences ? `Filtré sur ${sequencesAssociees.length} séquence(s) spécifique(s)` : 'Toutes les séquences'}`); + console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`); - // Récupérer toutes les séquences (ou uniquement celles associées) - let toutesSequences; - if (filtreSequences) { - // Seulement les séquences associées à ce rappel - toutesSequences = await db + // Récupérer les dates de formation concernées + let datesConcernees; + if (filtreDates) { + // Seulement les dates associées à ce rappel + datesConcernees = await db .select({ + dateFormation: datesFormation, sequence: sequences, formation: formations, formateur: formateurs, }) - .from(sequences) + .from(datesFormation) + .leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) .leftJoin(formations, eq(sequences.formationId, formations.id)) .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) - .where(inArray(sequences.id, sequencesAssociees)); + .where( + and( + inArray(datesFormation.id, datesAssociees), + gte(datesFormation.dateDebut, dateCible), + lte(datesFormation.dateDebut, dateCibleFin) + ) + ); } else { - // Toutes les séquences - toutesSequences = await db + // Toutes les dates correspondant à la date cible + datesConcernees = await db .select({ + dateFormation: datesFormation, sequence: sequences, formation: formations, formateur: formateurs, }) - .from(sequences) - .leftJoin(formations, eq(sequences.formationId, formations.id)) - .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)); - } - - // Filtrer les séquences dont la première date correspond à la date cible - const sequencesConcernees = []; - for (const { sequence, formation, formateur } of toutesSequences) { - if (!sequence || !formation) continue; - - // Récupérer la première date de cette séquence - const premiereDates = await db - .select() .from(datesFormation) + .leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) .where( and( - eq(datesFormation.sequenceId, sequence.id), - eq(datesFormation.ordre, 1) + gte(datesFormation.dateDebut, dateCible), + lte(datesFormation.dateDebut, dateCibleFin) ) - ) - .limit(1); - - if (premiereDates.length === 0) continue; - - const premiereDate = premiereDates[0]; - const dateDebut = new Date(premiereDate.dateDebut); - dateDebut.setHours(0, 0, 0, 0); - - // Vérifier si cette première date correspond à la date cible - if (dateDebut >= dateCible && dateDebut <= dateCibleFin) { - sequencesConcernees.push({ sequence, formation, formateur }); - } + ); } - console.log(`[Rappels] ${sequencesConcernees.length} séquence(s) trouvée(s)`); + console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`); - for (const { sequence, formation, formateur } of sequencesConcernees) { + // Grouper les dates par séquence pour envoyer un seul email par apprenant + const sequencesMap = new Map(); + + for (const { dateFormation, sequence, formation, formateur } of datesConcernees) { + if (!dateFormation || !sequence || !formation) continue; + + if (!sequencesMap.has(sequence.id)) { + sequencesMap.set(sequence.id, { + sequence, + formation, + formateur, + dates: [], + }); + } + sequencesMap.get(sequence.id)!.dates.push(dateFormation); + } + + console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`); + + for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) { if (!sequence || !formation) continue; - // Récupérer toutes les dates de cette séquence - const dates = await db + // Récupérer toutes les dates de cette séquence pour l'email + const toutesLesDates = await db .select() .from(datesFormation) .where(eq(datesFormation.sequenceId, sequence.id)) @@ -174,7 +184,7 @@ async function processRappel(rappel: any) { apprenantFonction: apprenant.fonction, formationNom: formation.nom, sequenceNom: sequence.nom, - dates: dates.map(d => ({ + dates: toutesLesDates.map(d => ({ dateDebut: d.dateDebut, dateFin: d.dateFin, ordre: d.ordre @@ -195,7 +205,7 @@ async function processRappel(rappel: any) { apprenantFonction: apprenant.fonction, formationNom: formation.nom, sequenceNom: sequence.nom, - dates: dates.map(d => ({ + dates: toutesLesDates.map(d => ({ dateDebut: d.dateDebut, dateFin: d.dateFin, ordre: d.ordre @@ -217,7 +227,7 @@ async function processRappel(rappel: any) { emailDestinataire: apprenant.email, typeRappel: rappel.templateType, statut: "success", - dateSequence: dates[0].dateDebut, + dateSequence: toutesLesDates[0].dateDebut, }); nbEnvoyes++; } catch (emailError: any) { @@ -233,7 +243,7 @@ async function processRappel(rappel: any) { typeRappel: rappel.templateType, statut: "failed", messageErreur: messageErreur, - dateSequence: dates[0].dateDebut, + dateSequence: toutesLesDates[0].dateDebut, }); nbEchecs++; echecs.push({ email: apprenant.email, erreur: messageErreur }); diff --git a/server/routers.ts b/server/routers.ts index ff72445..bde77f4 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1070,7 +1070,7 @@ export const appRouter = router({ joursAvant: z.number(), heureEnvoi: z.string().default("09:00"), actif: z.boolean(), - sequenceIds: z.array(z.number()).optional(), + dateFormationIds: z.array(z.number()).optional(), fichier: z.object({ nomFichier: z.string(), urlFichier: z.string(), @@ -1080,14 +1080,14 @@ export const appRouter = router({ }).nullable().optional(), })) .mutation(async ({ input }) => { - const { sequenceIds, ...rappelData } = input; + const { dateFormationIds, ...rappelData } = input; const result: any = await db.createRappel(rappelData); - // Créer les associations rappel-séquence si des séquences sont spécifiées - if (sequenceIds && sequenceIds.length > 0) { + // Créer les associations rappel-date si des dates sont spécifiées + if (dateFormationIds && dateFormationIds.length > 0) { const rappelId = result.insertId || result[0]?.insertId; if (rappelId) { - await db.createRappelSequences(Number(rappelId), sequenceIds); + await db.createRappelDates(Number(rappelId), dateFormationIds); } } @@ -1103,7 +1103,7 @@ export const appRouter = router({ joursAvant: z.number().optional(), heureEnvoi: z.string().optional(), actif: z.boolean().optional(), - sequenceIds: z.array(z.number()).optional(), + dateFormationIds: z.array(z.number()).optional(), fichier: z.object({ nomFichier: z.string(), urlFichier: z.string(), @@ -1113,14 +1113,14 @@ export const appRouter = router({ }).nullable().optional(), })) .mutation(async ({ input }) => { - const { id, sequenceIds, ...data } = input; + const { id, dateFormationIds, ...data } = input; await db.updateRappel(id, data); - // Mettre à jour les associations rappel-séquence si spécifiées - if (sequenceIds !== undefined) { - await db.deleteRappelSequences(id); - if (sequenceIds.length > 0) { - await db.createRappelSequences(id, sequenceIds); + // Mettre à jour les associations rappel-date si spécifiées + if (dateFormationIds !== undefined) { + await db.deleteRappelDates(id); + if (dateFormationIds.length > 0) { + await db.createRappelDates(id, dateFormationIds); } } diff --git a/todo.md b/todo.md index 310d785..f6efebc 100644 --- a/todo.md +++ b/todo.md @@ -549,3 +549,14 @@ - [ ] Adapter l'affichage du tableau pour montrer les séquences associées - [ ] Tester la création d'un rappel avec sélection de séquences spécifiques - [ ] Tester l'envoi automatique avec le nouveau système + +## Refonte granulaire : sélection par date de formation + +- [x] Modifier rappelSequences pour pointer vers dateFormationId au lieu de sequenceId +- [x] Créer le composant DateFormationMultiSelect hiérarchique +- [x] Intégrer le nouveau sélecteur dans AdminRappels +- [x] Adapter les fonctions DB pour gérer dateFormationId +- [x] Mettre à jour les procédures tRPC pour dateFormationId +- [x] Modifier le scheduler pour filtrer par dateFormationId +- [ ] Tester la création avec dates spécifiques +- [ ] Vérifier l'affichage et l'édition