Checkpoint: Refonte complète du système de rappels pour permettre une gestion granulaire par séquence

Fonctionnalités ajoutées :
- Table de liaison rappelSequences pour associer rappels et séquences
- Composant SequenceMultiSelect avec recherche et multi-sélection
- Gestion des associations dans les procédures create/update
- Modification du scheduler pour filtrer les séquences selon les associations
- Si aucune séquence n'est sélectionnée, le rappel s'applique à toutes les séquences (comportement par défaut)

Le système permet maintenant de configurer des rappels spécifiques pour certaines dates/séquences particulières, offrant une flexibilité complète dans la gestion des rappels automatiques.
This commit is contained in:
Manus
2026-01-08 08:27:20 -05:00
parent 5de295a18c
commit 6ee87f9b22
12 changed files with 2462 additions and 15 deletions

View File

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE IF NOT EXISTS `rappelSequences` (\n `id` int AUTO_INCREMENT NOT NULL,\n `rappelId` int NOT NULL,\n `sequenceId` int NOT NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `rappelSequences_id` PRIMARY KEY(`id`)\n);",
"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 CREATE TABLE IF NOT EXISTS `rappelSequences` (\n `id` int AUTO_INCREMENT NOT NULL,\n `rappelId` int NOT NULL,\n `sequenceId` int NOT NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `rappelSequences_id` PRIMARY KEY(`id`)\n);",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 652
}

View File

@@ -0,0 +1,9 @@
{
"query": "SELECT * FROM rappelSequences;",
"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 * FROM rappelSequences;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 119
}

View File

@@ -0,0 +1,16 @@
{
"query": "SELECT r.id, r.nom, rs.sequenceId, s.nom as sequenceNom FROM rappels r LEFT JOIN rappelSequences rs ON r.id = rs.rappelId LEFT JOIN sequences s ON rs.sequenceId = s.id WHERE r.nom = 'Rappel 2' ORDER BY r.id DESC LIMIT 5;",
"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 r.id, r.nom, rs.sequenceId, s.nom as sequenceNom FROM rappels r LEFT JOIN rappelSequences rs ON r.id = rs.rappelId LEFT JOIN sequences s ON rs.sequenceId = s.id WHERE r.nom = 'Rappel 2' ORDER BY r.id DESC LIMIT 5;",
"rows": [
{
"id": "60002",
"nom": "Rappel 2",
"sequenceId": "90002",
"sequenceNom": "Groupe B"
}
],
"messages": [],
"stdout": "id\tnom\tsequenceId\tsequenceNom\n60002\tRappel 2\t90002\tGroupe B\n",
"stderr": "",
"execution_time_ms": 58
}

View File

@@ -0,0 +1,122 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
interface Sequence {
id: number;
nom: string;
formationNom?: string;
}
interface SequenceMultiSelectProps {
sequences: Sequence[];
selectedIds: number[];
onChange: (selectedIds: number[]) => void;
placeholder?: string;
}
export function SequenceMultiSelect({
sequences,
selectedIds,
onChange,
placeholder = "Sélectionnez des séquences...",
}: SequenceMultiSelectProps) {
const [open, setOpen] = useState(false);
const toggleSequence = (sequenceId: number) => {
const newSelection = selectedIds.includes(sequenceId)
? selectedIds.filter((id) => id !== sequenceId)
: [...selectedIds, sequenceId];
onChange(newSelection);
};
const selectedSequences = sequences.filter((seq) =>
selectedIds.includes(seq.id)
);
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedIds.length === 0 ? (
<span className="text-muted-foreground">{placeholder}</span>
) : (
<span>
{selectedIds.length} séquence(s) sélectionnée(s)
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Rechercher une séquence..." />
<CommandEmpty>Aucune séquence trouvée.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{sequences.map((sequence) => (
<CommandItem
key={sequence.id}
onSelect={() => toggleSequence(sequence.id)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedIds.includes(sequence.id)
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{sequence.nom}</span>
{sequence.formationNom && (
<span className="text-xs text-muted-foreground">
{sequence.formationNom}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
{selectedSequences.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSequences.map((sequence) => (
<Badge
key={sequence.id}
variant="secondary"
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
onClick={() => toggleSequence(sequence.id)}
>
{sequence.nom}
<span className="ml-1">×</span>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -25,6 +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";
// Types de templates disponibles pour les rappels // Types de templates disponibles pour les rappels
const TEMPLATE_TYPES = [ const TEMPLATE_TYPES = [
@@ -47,6 +48,7 @@ export default function AdminRappels() {
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
actif: true, actif: true,
sequenceIds: [] as number[],
fichier: null as { fichier: null as {
nomFichier: string; nomFichier: string;
urlFichier: string; urlFichier: string;
@@ -57,6 +59,7 @@ export default function AdminRappels() {
}); });
const { data: rappels, refetch } = trpc.rappels.list.useQuery(); const { data: rappels, refetch } = trpc.rappels.list.useQuery();
const { data: sequences } = trpc.sequences.list.useQuery();
const createMutation = trpc.rappels.create.useMutation({ const createMutation = trpc.rappels.create.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Rappel créé avec succès"); toast.success("Rappel créé avec succès");
@@ -111,6 +114,7 @@ export default function AdminRappels() {
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
actif: true, actif: true,
sequenceIds: [],
}); });
}; };
@@ -131,6 +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 || [],
fichier: rappel.nomFichier ? { fichier: rappel.nomFichier ? {
nomFichier: rappel.nomFichier, nomFichier: rappel.nomFichier,
urlFichier: rappel.urlFichier, urlFichier: rappel.urlFichier,
@@ -376,6 +381,23 @@ export default function AdminRappels() {
</p> </p>
</div> </div>
<div className="space-y-2">
<Label>Séquences concernées (optionnel)</Label>
<SequenceMultiSelect
sequences={sequences?.map(seq => ({
id: seq.id,
nom: seq.nom,
formationNom: seq.formation?.nom
})) || []}
selectedIds={formData.sequenceIds}
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })}
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>
<FileUpload <FileUpload
value={formData.fichier} value={formData.fichier}
onChange={(fichier) => setFormData({ ...formData, fichier })} onChange={(fichier) => setFormData({ ...formData, fichier })}
@@ -503,6 +525,23 @@ export default function AdminRappels() {
/> />
</div> </div>
<div className="space-y-2">
<Label>Séquences concernées (optionnel)</Label>
<SequenceMultiSelect
sequences={sequences?.map(seq => ({
id: seq.id,
nom: seq.nom,
formationNom: seq.formation?.nom
})) || []}
selectedIds={formData.sequenceIds}
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })}
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>
<FileUpload <FileUpload
value={formData.fichier} value={formData.fichier}
onChange={(fichier) => setFormData({ ...formData, fichier })} onChange={(fichier) => setFormData({ ...formData, fichier })}

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,13 @@
"when": 1767869539717, "when": 1767869539717,
"tag": "0029_classy_warbound", "tag": "0029_classy_warbound",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "5",
"when": 1767877943207,
"tag": "0030_cultured_korg",
"breakpoints": true
} }
] ]
} }

View File

@@ -305,6 +305,23 @@ export const rappels = mysqlTable("rappels", {
export type Rappel = typeof rappels.$inferSelect; 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)
* Permet de définir quelles séquences sont concernées par chaque rappel
* Si aucune association n'existe pour un rappel, il s'applique à toutes les séquences
*/
export const rappelSequences = mysqlTable("rappelSequences", {
id: int("id").autoincrement().primaryKey(),
/** ID du rappel */
rappelId: int("rappelId").notNull(),
/** ID de la séquence concernée */
sequenceId: int("sequenceId").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type RappelSequence = typeof rappelSequences.$inferSelect;
export type InsertRappelSequence = typeof rappelSequences.$inferInsert;
/** /**
* Table des questionnaires * Table des questionnaires
* Stocke les questionnaires de satisfaction et d'évaluation des acquis * Stocke les questionnaires de satisfaction et d'évaluation des acquis

View File

@@ -29,7 +29,8 @@ import {
Formateur, Formateur,
rappels, rappels,
InsertRappel, InsertRappel,
Rappel Rappel,
rappelSequences
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -969,7 +970,20 @@ export async function getRappels() {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return 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
const rappelsWithSequences = await Promise.all(
allRappels.map(async (rappel) => {
const sequenceIds = await getRappelSequences(rappel.id);
return {
...rappel,
sequenceIds,
};
})
);
return rappelsWithSequences;
} }
export async function getRappelById(id: number): Promise<Rappel | undefined> { export async function getRappelById(id: number): Promise<Rappel | undefined> {
@@ -1022,9 +1036,43 @@ export async function deleteRappel(id: 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");
// Supprimer d'abord les associations
await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, id));
// Puis le rappel
await db.delete(rappels).where(eq(rappels.id, id)); await db.delete(rappels).where(eq(rappels.id, id));
} }
// ==================== RAPPEL SEQUENCES ====================
export async function createRappelSequences(rappelId: number, sequenceIds: number[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (sequenceIds.length === 0) return;
const values = sequenceIds.map(sequenceId => ({
rappelId,
sequenceId,
}));
await db.insert(rappelSequences).values(values);
}
export async function deleteRappelSequences(rappelId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, rappelId));
}
export async function getRappelSequences(rappelId: number): Promise<number[]> {
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);
}
export async function updateRappelExecution(id: number) { export async function updateRappelExecution(id: number) {
const db = await getDb(); const db = await getDb();
if (!db) return; if (!db) return;

View File

@@ -1,6 +1,6 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema"; import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema";
import { eq, and, gte, lte, sql } from "drizzle-orm"; import { eq, and, gte, lte, sql, inArray } from "drizzle-orm";
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService"; import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb"; import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb";
import { notifyOwner } from "./_core/notification"; import { notifyOwner } from "./_core/notification";
@@ -55,8 +55,29 @@ async function processRappel(rappel: any) {
console.log(`[Rappels] Recherche des séquences débutant le ${dateCible.toLocaleDateString()}`); console.log(`[Rappels] Recherche des séquences débutant le ${dateCible.toLocaleDateString()}`);
// CORRECTION DU BUG: Récupérer d'abord toutes les séquences // Vérifier si ce rappel est associé à des séquences spécifiques
const toutesSequences = await db const sequencesAssociees = rappel.sequenceIds || [];
const filtreSequences = sequencesAssociees.length > 0;
console.log(`[Rappels] ${filtreSequences ? `Filtré sur ${sequencesAssociees.length} séquence(s) spécifique(s)` : 'Toutes les séquences'}`);
// 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
.select({
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(sequences)
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(inArray(sequences.id, sequencesAssociees));
} else {
// Toutes les séquences
toutesSequences = await db
.select({ .select({
sequence: sequences, sequence: sequences,
formation: formations, formation: formations,
@@ -65,6 +86,7 @@ async function processRappel(rappel: any) {
.from(sequences) .from(sequences)
.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));
}
// Filtrer les séquences dont la première date correspond à la date cible // Filtrer les séquences dont la première date correspond à la date cible
const sequencesConcernees = []; const sequencesConcernees = [];

View File

@@ -1070,6 +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(),
fichier: z.object({ fichier: z.object({
nomFichier: z.string(), nomFichier: z.string(),
urlFichier: z.string(), urlFichier: z.string(),
@@ -1079,7 +1080,18 @@ export const appRouter = router({
}).nullable().optional(), }).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return db.createRappel(input); const { sequenceIds, ...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) {
const rappelId = result.insertId || result[0]?.insertId;
if (rappelId) {
await db.createRappelSequences(Number(rappelId), sequenceIds);
}
}
return result;
}), }),
update: adminProcedure update: adminProcedure
@@ -1091,6 +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(),
fichier: z.object({ fichier: z.object({
nomFichier: z.string(), nomFichier: z.string(),
urlFichier: z.string(), urlFichier: z.string(),
@@ -1100,8 +1113,17 @@ export const appRouter = router({
}).nullable().optional(), }).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, ...data } = input; const { id, sequenceIds, ...data } = input;
await db.updateRappel(id, data); 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);
}
}
return { success: true }; return { success: true };
}), }),

12
todo.md
View File

@@ -537,3 +537,15 @@
## Correction affichage délai rappels post-formation ## Correction affichage délai rappels post-formation
- [x] Corriger l.affichage de la colonne "Délai" pour afficher J+X pour les rappels post-formation - [x] Corriger l.affichage de la colonne "Délai" pour afficher J+X pour les rappels post-formation
- [x] Tester l.affichage avec un rappel pré-formation et un rappel post-formation - [x] Tester l.affichage avec un rappel pré-formation et un rappel post-formation
## Refonte système de rappels - Gestion par séquence
- [x] Créer la table rappelSequences (relation many-to-many entre rappels et séquences)
- [x] Appliquer la migration en base de données
- [x] Créer un composant de sélection multi-séquences
- [x] Modifier le formulaire AdminRappels pour intégrer le sélecteur de séquences
- [x] Créer les fonctions DB pour gérer les associations rappel-séquence
- [x] Mettre à jour les procédures tRPC create et update pour gérer les associations
- [x] Modifier le scheduler pour ne traiter que les séquences associées à chaque rappel
- [ ] 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