Checkpoint: Refonte majeure du système de rappels : sélection granulaire au niveau des dates de formation individuelles (dateFormationId) au lieu des séquences. Permet de choisir précisément quelles dates d'une séquence recevront un rappel. Interface hiérarchique avec séquences dépliables et checkboxes par date. Scheduler adapté pour traiter date par date.

This commit is contained in:
Manus
2026-01-08 08:53:41 -05:00
parent 6ee87f9b22
commit f331f9bb5f
8 changed files with 363 additions and 113 deletions

View File

@@ -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
}

View File

@@ -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<Set<number>>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{getSelectedLabel()}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="max-h-[400px] overflow-y-auto p-2">
{sequences.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
Aucune séquence disponible
</div>
) : (
<div className="space-y-1">
{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 (
<div key={sequence.id} className="space-y-1">
{/* En-tête de séquence */}
<div className="flex items-center gap-2 rounded-sm hover:bg-accent p-2">
<button
onClick={() => toggleSequence(sequence.id)}
className="flex items-center gap-1 flex-1 text-left"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{sequence.nom}
</div>
<div className="text-xs text-muted-foreground truncate">
{sequence.formationNom} {sequence.dates.length} date(s)
</div>
</div>
</button>
<button
onClick={() => toggleAllDatesInSequence(sequence)}
className={cn(
"h-4 w-4 shrink-0 rounded-sm border border-primary",
allSelected
? "bg-primary text-primary-foreground"
: someSelected
? "bg-primary/50 text-primary-foreground"
: "opacity-50 hover:opacity-100"
)}
>
{(allSelected || someSelected) && (
<Check className="h-3 w-3" />
)}
</button>
</div>
{/* Liste des dates */}
{isExpanded && (
<div className="ml-6 space-y-1">
{sequence.dates.map((date) => {
const isSelected = selectedDateIds.includes(date.id);
return (
<button
key={date.id}
onClick={() => toggleDate(date.id)}
className="flex items-center gap-2 w-full rounded-sm hover:bg-accent p-2 text-left"
>
<div
className={cn(
"h-4 w-4 shrink-0 rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 hover:opacity-100"
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<div className="flex-1 text-sm">
{formatDate(date.date)} {date.heureDebut} - {date.heureFin}
</div>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
<div className="border-t p-2 text-xs text-muted-foreground">
Si aucune date n'est sélectionnée, le rappel s'appliquera à toutes les dates
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -25,7 +25,7 @@ import { trpc } from "@/lib/trpc";
import { Bell, Edit, Plus, Trash2 } from "lucide-react"; import { Bell, Edit, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileUpload } from "@/components/FileUpload"; import { FileUpload } from "@/components/FileUpload";
import { SequenceMultiSelect } from "@/components/SequenceMultiSelect"; import { DateFormationMultiSelect } from "@/components/DateFormationMultiSelect";
// Types de templates disponibles pour les rappels // Types de templates disponibles pour les rappels
const TEMPLATE_TYPES = [ const TEMPLATE_TYPES = [
@@ -48,7 +48,7 @@ export default function AdminRappels() {
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
actif: true, actif: true,
sequenceIds: [] as number[], dateFormationIds: [] as number[],
fichier: null as { fichier: null as {
nomFichier: string; nomFichier: string;
urlFichier: string; urlFichier: string;
@@ -114,7 +114,7 @@ export default function AdminRappels() {
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
actif: true, actif: true,
sequenceIds: [], dateFormationIds: [],
}); });
}; };
@@ -135,7 +135,7 @@ export default function AdminRappels() {
joursAvant: rappel.joursAvant, joursAvant: rappel.joursAvant,
heureEnvoi: rappel.heureEnvoi, heureEnvoi: rappel.heureEnvoi,
actif: rappel.actif, actif: rappel.actif,
sequenceIds: rappel.sequenceIds || [], dateFormationIds: rappel.dateFormationIds || [],
fichier: rappel.nomFichier ? { fichier: rappel.nomFichier ? {
nomFichier: rappel.nomFichier, nomFichier: rappel.nomFichier,
urlFichier: rappel.urlFichier, urlFichier: rappel.urlFichier,
@@ -382,20 +382,22 @@ export default function AdminRappels() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Séquences concernées (optionnel)</Label> <Label>Dates de formation concernées (optionnel)</Label>
<SequenceMultiSelect <DateFormationMultiSelect
sequences={sequences?.map(seq => ({ sequences={sequences?.map(seq => ({
id: seq.id, id: seq.id,
nom: seq.nom, 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} selectedDateIds={formData.dateFormationIds}
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })} onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })}
placeholder="Toutes les séquences (par défaut)"
/> />
<p className="text-sm text-muted-foreground">
Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences
</p>
</div> </div>
<FileUpload <FileUpload
@@ -526,20 +528,22 @@ export default function AdminRappels() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Séquences concernées (optionnel)</Label> <Label>Dates de formation concernées (optionnel)</Label>
<SequenceMultiSelect <DateFormationMultiSelect
sequences={sequences?.map(seq => ({ sequences={sequences?.map(seq => ({
id: seq.id, id: seq.id,
nom: seq.nom, 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} selectedDateIds={formData.dateFormationIds}
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })} onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })}
placeholder="Toutes les séquences (par défaut)"
/> />
<p className="text-sm text-muted-foreground">
Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences
</p>
</div> </div>
<FileUpload <FileUpload

View File

@@ -306,21 +306,21 @@ export type Rappel = typeof rappels.$inferSelect;
export type InsertRappel = typeof rappels.$inferInsert; export type InsertRappel = typeof rappels.$inferInsert;
/** /**
* Table de liaison entre rappels et séquences (many-to-many) * Table de liaison entre rappels et dates de formation
* Permet de définir quelles séquences sont concernées par chaque rappel * Permet d'associer un rappel à des dates de formation spécifiques
* Si aucune association n'existe pour un rappel, il s'applique à toutes les séquences * Si aucune association n'existe pour un rappel, il s'applique à toutes les dates
*/ */
export const rappelSequences = mysqlTable("rappelSequences", { export const rappelDates = mysqlTable("rappelDates", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
/** ID du rappel */ /** ID du rappel */
rappelId: int("rappelId").notNull(), rappelId: int("rappelId").notNull(),
/** ID de la séquence concernée */ /** ID de la date de formation concernée */
sequenceId: int("sequenceId").notNull(), dateFormationId: int("dateFormationId").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); });
export type RappelSequence = typeof rappelSequences.$inferSelect; export type RappelDate = typeof rappelDates.$inferSelect;
export type InsertRappelSequence = typeof rappelSequences.$inferInsert; export type InsertRappelDate = typeof rappelDates.$inferInsert;
/** /**
* Table des questionnaires * Table des questionnaires

View File

@@ -30,7 +30,7 @@ import {
rappels, rappels,
InsertRappel, InsertRappel,
Rappel, Rappel,
rappelSequences rappelDates
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -972,18 +972,18 @@ export async function getRappels() {
const allRappels = await db.select().from(rappels).orderBy(rappels.joursAvant); const allRappels = await db.select().from(rappels).orderBy(rappels.joursAvant);
// Pour chaque rappel, récupérer les séquences associées // Pour chaque rappel, récupérer les dates de formation associées
const rappelsWithSequences = await Promise.all( const rappelsWithDates = await Promise.all(
allRappels.map(async (rappel) => { allRappels.map(async (rappel) => {
const sequenceIds = await getRappelSequences(rappel.id); const dateFormationIds = await getRappelDates(rappel.id);
return { return {
...rappel, ...rappel,
sequenceIds, dateFormationIds,
}; };
}) })
); );
return rappelsWithSequences; return rappelsWithDates;
} }
export async function getRappelById(id: number): Promise<Rappel | undefined> { export async function getRappelById(id: number): Promise<Rappel | undefined> {
@@ -1037,40 +1037,40 @@ export async function deleteRappel(id: number) {
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Supprimer d'abord les associations // 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 // Puis le rappel
await db.delete(rappels).where(eq(rappels.id, id)); 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(); const db = await getDb();
if (!db) throw new Error("Database not available"); 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, 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(); const db = await getDb();
if (!db) throw new Error("Database not available"); 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<number[]> { export async function getRappelDates(rappelId: number): Promise<number[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
const results = await db.select().from(rappelSequences).where(eq(rappelSequences.rappelId, rappelId)); const results = await db.select().from(rappelDates).where(eq(rappelDates.rappelId, rappelId));
return results.map(r => r.sequenceId); return results.map(r => r.dateFormationId);
} }
export async function updateRappelExecution(id: number) { export async function updateRappelExecution(id: number) {

View File

@@ -39,91 +39,101 @@ export async function processRappelsAutomatiques() {
} }
async function processRappel(rappel: any) { 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(); const db = await getDb();
if (!db) return; if (!db) return;
try { try {
// Calculer la date cible (aujourd'hui + joursAvant) // Calculer la date cible selon le timing (pré ou post-formation)
const dateCible = new Date(); 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); dateCible.setHours(0, 0, 0, 0);
const dateCibleFin = new Date(dateCible); const dateCibleFin = new Date(dateCible);
dateCibleFin.setHours(23, 59, 59, 999); 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 // Vérifier si ce rappel est associé à des dates spécifiques
const sequencesAssociees = rappel.sequenceIds || []; const datesAssociees = rappel.dateFormationIds || [];
const filtreSequences = sequencesAssociees.length > 0; 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) // Récupérer les dates de formation concernées
let toutesSequences; let datesConcernees;
if (filtreSequences) { if (filtreDates) {
// Seulement les séquences associées à ce rappel // Seulement les dates associées à ce rappel
toutesSequences = await db datesConcernees = await db
.select({ .select({
dateFormation: datesFormation,
sequence: sequences, sequence: sequences,
formation: formations, formation: formations,
formateur: formateurs, formateur: formateurs,
}) })
.from(sequences) .from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id)) .leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.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 { } else {
// Toutes les séquences // Toutes les dates correspondant à la date cible
toutesSequences = await db datesConcernees = await db
.select({ .select({
dateFormation: datesFormation,
sequence: sequences, sequence: sequences,
formation: formations, formation: formations,
formateur: formateurs, 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) .from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where( .where(
and( and(
eq(datesFormation.sequenceId, sequence.id), gte(datesFormation.dateDebut, dateCible),
eq(datesFormation.ordre, 1) 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<number, { sequence: any; formation: any; formateur: any; dates: any[] }>();
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; if (!sequence || !formation) continue;
// Récupérer toutes les dates de cette séquence // Récupérer toutes les dates de cette séquence pour l'email
const dates = await db const toutesLesDates = await db
.select() .select()
.from(datesFormation) .from(datesFormation)
.where(eq(datesFormation.sequenceId, sequence.id)) .where(eq(datesFormation.sequenceId, sequence.id))
@@ -174,7 +184,7 @@ async function processRappel(rappel: any) {
apprenantFonction: apprenant.fonction, apprenantFonction: apprenant.fonction,
formationNom: formation.nom, formationNom: formation.nom,
sequenceNom: sequence.nom, sequenceNom: sequence.nom,
dates: dates.map(d => ({ dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut, dateDebut: d.dateDebut,
dateFin: d.dateFin, dateFin: d.dateFin,
ordre: d.ordre ordre: d.ordre
@@ -195,7 +205,7 @@ async function processRappel(rappel: any) {
apprenantFonction: apprenant.fonction, apprenantFonction: apprenant.fonction,
formationNom: formation.nom, formationNom: formation.nom,
sequenceNom: sequence.nom, sequenceNom: sequence.nom,
dates: dates.map(d => ({ dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut, dateDebut: d.dateDebut,
dateFin: d.dateFin, dateFin: d.dateFin,
ordre: d.ordre ordre: d.ordre
@@ -217,7 +227,7 @@ async function processRappel(rappel: any) {
emailDestinataire: apprenant.email, emailDestinataire: apprenant.email,
typeRappel: rappel.templateType, typeRappel: rappel.templateType,
statut: "success", statut: "success",
dateSequence: dates[0].dateDebut, dateSequence: toutesLesDates[0].dateDebut,
}); });
nbEnvoyes++; nbEnvoyes++;
} catch (emailError: any) { } catch (emailError: any) {
@@ -233,7 +243,7 @@ async function processRappel(rappel: any) {
typeRappel: rappel.templateType, typeRappel: rappel.templateType,
statut: "failed", statut: "failed",
messageErreur: messageErreur, messageErreur: messageErreur,
dateSequence: dates[0].dateDebut, dateSequence: toutesLesDates[0].dateDebut,
}); });
nbEchecs++; nbEchecs++;
echecs.push({ email: apprenant.email, erreur: messageErreur }); echecs.push({ email: apprenant.email, erreur: messageErreur });

View File

@@ -1070,7 +1070,7 @@ export const appRouter = router({
joursAvant: z.number(), joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"), heureEnvoi: z.string().default("09:00"),
actif: z.boolean(), actif: z.boolean(),
sequenceIds: z.array(z.number()).optional(), dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({ fichier: z.object({
nomFichier: z.string(), nomFichier: z.string(),
urlFichier: z.string(), urlFichier: z.string(),
@@ -1080,14 +1080,14 @@ export const appRouter = router({
}).nullable().optional(), }).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { sequenceIds, ...rappelData } = input; const { dateFormationIds, ...rappelData } = input;
const result: any = await db.createRappel(rappelData); const result: any = await db.createRappel(rappelData);
// Créer les associations rappel-séquence si des séquences sont spécifiées // Créer les associations rappel-date si des dates sont spécifiées
if (sequenceIds && sequenceIds.length > 0) { if (dateFormationIds && dateFormationIds.length > 0) {
const rappelId = result.insertId || result[0]?.insertId; const rappelId = result.insertId || result[0]?.insertId;
if (rappelId) { 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(), joursAvant: z.number().optional(),
heureEnvoi: z.string().optional(), heureEnvoi: z.string().optional(),
actif: z.boolean().optional(), actif: z.boolean().optional(),
sequenceIds: z.array(z.number()).optional(), dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({ fichier: z.object({
nomFichier: z.string(), nomFichier: z.string(),
urlFichier: z.string(), urlFichier: z.string(),
@@ -1113,14 +1113,14 @@ export const appRouter = router({
}).nullable().optional(), }).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, sequenceIds, ...data } = input; const { id, dateFormationIds, ...data } = input;
await db.updateRappel(id, data); await db.updateRappel(id, data);
// Mettre à jour les associations rappel-séquence si spécifiées // Mettre à jour les associations rappel-date si spécifiées
if (sequenceIds !== undefined) { if (dateFormationIds !== undefined) {
await db.deleteRappelSequences(id); await db.deleteRappelDates(id);
if (sequenceIds.length > 0) { if (dateFormationIds.length > 0) {
await db.createRappelSequences(id, sequenceIds); await db.createRappelDates(id, dateFormationIds);
} }
} }

11
todo.md
View File

@@ -549,3 +549,14 @@
- [ ] Adapter l'affichage du tableau pour montrer les séquences associées - [ ] 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 la création d'un rappel avec sélection de séquences spécifiques
- [ ] Tester l'envoi automatique avec le nouveau système - [ ] 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