Checkpoint: Ajout des types de rappels J-7 et J-1 dans l'interface de gestion des rappels. Les templates d'emails ont été corrigés pour afficher toutes les dates des séquences. Bug connu : l'insertion des rappels échoue avec une erreur SQL sur le champ derniereExecution (à corriger après ce checkpoint).

This commit is contained in:
Manus Sandbox
2025-11-30 17:24:41 -05:00
parent d84a2fe0c5
commit 548dbc12b9
9 changed files with 1375 additions and 70 deletions

View File

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE IF NOT EXISTS `rappels` (\n `id` int AUTO_INCREMENT NOT NULL,\n `nom` varchar(255) NOT NULL,\n `templateType` varchar(50) NOT NULL,\n `joursAvant` int NOT NULL,\n `heureEnvoi` varchar(5) NOT NULL DEFAULT '09:00',\n `actif` boolean NOT NULL DEFAULT true,\n `derniereExecution` timestamp NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updatedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n CONSTRAINT `rappels_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 `rappels` (\n `id` int AUTO_INCREMENT NOT NULL,\n `nom` varchar(255) NOT NULL,\n `templateType` varchar(50) NOT NULL,\n `joursAvant` int NOT NULL,\n `heureEnvoi` varchar(5) NOT NULL DEFAULT '09:00',\n `actif` boolean NOT NULL DEFAULT true,\n `derniereExecution` timestamp NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updatedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n CONSTRAINT `rappels_id` PRIMARY KEY(`id`)\n)",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 112
}

View File

@@ -12,22 +12,34 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; 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";
// Types de templates disponibles pour les rappels
const TEMPLATE_TYPES = [
{ value: "rappel", label: "Rappel J-7", joursAvant: 7 },
{ value: "rappelJ1", label: "Rappel J-1", joursAvant: 1 },
];
export default function AdminRappels() { export default function AdminRappels() {
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
const [editingRappel, setEditingRappel] = useState<any>(null); const [editingRappel, setEditingRappel] = useState<any>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nom: "", nom: "",
joursAvant: 0, templateType: "",
sujet: "", joursAvant: 7,
contenu: "", heureEnvoi: "09:00",
actif: true, actif: true,
}); });
@@ -67,18 +79,28 @@ export default function AdminRappels() {
}, },
}); });
const toggleActifMutation = trpc.rappels.toggleActif.useMutation({
onSuccess: () => {
toast.success("Statut modifié avec succès");
refetch();
},
onError: (error) => {
toast.error(`Erreur: ${error.message}`);
},
});
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
nom: "", nom: "",
joursAvant: 0, templateType: "",
sujet: "", joursAvant: 7,
contenu: "", heureEnvoi: "09:00",
actif: true, actif: true,
}); });
}; };
const handleCreate = () => { const handleCreate = () => {
if (!formData.nom || !formData.sujet || !formData.contenu) { if (!formData.nom || !formData.templateType) {
toast.error("Veuillez remplir tous les champs obligatoires"); toast.error("Veuillez remplir tous les champs obligatoires");
return; return;
} }
@@ -89,9 +111,9 @@ export default function AdminRappels() {
setEditingRappel(rappel); setEditingRappel(rappel);
setFormData({ setFormData({
nom: rappel.nom, nom: rappel.nom,
templateType: rappel.templateType,
joursAvant: rappel.joursAvant, joursAvant: rappel.joursAvant,
sujet: rappel.sujet, heureEnvoi: rappel.heureEnvoi,
contenu: rappel.contenu,
actif: rappel.actif, actif: rappel.actif,
}); });
setIsEditOpen(true); setIsEditOpen(true);
@@ -99,7 +121,7 @@ export default function AdminRappels() {
const handleUpdate = () => { const handleUpdate = () => {
if (!editingRappel) return; if (!editingRappel) return;
if (!formData.nom || !formData.sujet || !formData.contenu) { if (!formData.nom || !formData.templateType) {
toast.error("Veuillez remplir tous les champs obligatoires"); toast.error("Veuillez remplir tous les champs obligatoires");
return; return;
} }
@@ -115,6 +137,24 @@ export default function AdminRappels() {
} }
}; };
const handleToggleActif = (id: number, actif: boolean) => {
toggleActifMutation.mutate({ id, actif: !actif });
};
const handleTemplateTypeChange = (value: string) => {
const template = TEMPLATE_TYPES.find(t => t.value === value);
setFormData({
...formData,
templateType: value,
joursAvant: template?.joursAvant || 7,
nom: template?.label || "",
});
};
const getTemplateName = (type: string) => {
return TEMPLATE_TYPES.find(t => t.value === type)?.label || type;
};
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
@@ -146,8 +186,9 @@ export default function AdminRappels() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nom</TableHead> <TableHead>Nom</TableHead>
<TableHead>Type</TableHead>
<TableHead>Délai</TableHead> <TableHead>Délai</TableHead>
<TableHead>Sujet</TableHead> <TableHead>Heure</TableHead>
<TableHead>Statut</TableHead> <TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@@ -157,18 +198,20 @@ export default function AdminRappels() {
rappels.map((rappel) => ( rappels.map((rappel) => (
<TableRow key={rappel.id}> <TableRow key={rappel.id}>
<TableCell className="font-medium">{rappel.nom}</TableCell> <TableCell className="font-medium">{rappel.nom}</TableCell>
<TableCell>{getTemplateName(rappel.templateType)}</TableCell>
<TableCell>J-{rappel.joursAvant}</TableCell> <TableCell>J-{rappel.joursAvant}</TableCell>
<TableCell>{rappel.sujet}</TableCell> <TableCell>{rappel.heureEnvoi}</TableCell>
<TableCell> <TableCell>
<span <button
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ onClick={() => handleToggleActif(rappel.id, rappel.actif)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium cursor-pointer ${
rappel.actif rappel.actif
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800" : "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`} }`}
> >
{rappel.actif ? "Actif" : "Inactif"} {rappel.actif ? "Actif" : "Inactif"}
</span> </button>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -192,7 +235,7 @@ export default function AdminRappels() {
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground"> <TableCell colSpan={6} className="text-center text-muted-foreground">
Aucun rappel configuré Aucun rappel configuré
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -212,13 +255,35 @@ export default function AdminRappels() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="templateType">Type de rappel *</Label>
<Select
value={formData.templateType}
onValueChange={handleTemplateTypeChange}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un type de rappel" />
</SelectTrigger>
<SelectContent>
{TEMPLATE_TYPES.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Le template d'email correspondant sera utilisé pour l'envoi
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nom">Nom du rappel *</Label> <Label htmlFor="nom">Nom du rappel *</Label>
<Input <Input
id="nom" id="nom"
value={formData.nom} value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })} onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
placeholder="Ex: Rappel J-15" placeholder="Ex: Rappel automatique J-7"
/> />
</div> </div>
@@ -232,7 +297,7 @@ export default function AdminRappels() {
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 }) setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 })
} }
placeholder="Ex: 15" placeholder="Ex: 7"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Le rappel sera envoyé ce nombre de jours avant le début de la séquence Le rappel sera envoyé ce nombre de jours avant le début de la séquence
@@ -240,26 +305,15 @@ export default function AdminRappels() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sujet">Sujet de l'email *</Label> <Label htmlFor="heureEnvoi">Heure d'envoi *</Label>
<Input <Input
id="sujet" id="heureEnvoi"
value={formData.sujet} type="time"
onChange={(e) => setFormData({ ...formData, sujet: e.target.value })} value={formData.heureEnvoi}
placeholder="Ex: Rappel - Formation dans 15 jours" onChange={(e) => setFormData({ ...formData, heureEnvoi: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="contenu">Contenu de l'email *</Label>
<Textarea
id="contenu"
value={formData.contenu}
onChange={(e) => setFormData({ ...formData, contenu: e.target.value })}
placeholder="Utilisez {{nom_apprenant}}, {{titre_formation}}, {{date_debut}}, {{lieu}} comme variables"
rows={8}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Variables disponibles : {"{"}{"{"} nom_apprenant {"}"}{"}"}, {"{"}{"{"} titre_formation {"}"}{"}"}, {"{"}{"{"} date_debut {"}"}{"}"}, {"{"}{"{"} lieu {"}"}{"}"}, {"{"}{"{"} formateur {"}"}{"}"} L'heure à laquelle le rappel sera envoyé chaque jour
</p> </p>
</div> </div>
@@ -293,13 +347,32 @@ export default function AdminRappels() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-templateType">Type de rappel *</Label>
<Select
value={formData.templateType}
onValueChange={handleTemplateTypeChange}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un type de rappel" />
</SelectTrigger>
<SelectContent>
{TEMPLATE_TYPES.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="edit-nom">Nom du rappel *</Label> <Label htmlFor="edit-nom">Nom du rappel *</Label>
<Input <Input
id="edit-nom" id="edit-nom"
value={formData.nom} value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })} onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
placeholder="Ex: Rappel J-15" placeholder="Ex: Rappel automatique J-7"
/> />
</div> </div>
@@ -313,34 +386,20 @@ export default function AdminRappels() {
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 }) setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 })
} }
placeholder="Ex: 15" placeholder="Ex: 7"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="edit-sujet">Sujet de l'email *</Label> <Label htmlFor="edit-heureEnvoi">Heure d'envoi *</Label>
<Input <Input
id="edit-sujet" id="edit-heureEnvoi"
value={formData.sujet} type="time"
onChange={(e) => setFormData({ ...formData, sujet: e.target.value })} value={formData.heureEnvoi}
placeholder="Ex: Rappel - Formation dans 15 jours" onChange={(e) => setFormData({ ...formData, heureEnvoi: e.target.value })}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="edit-contenu">Contenu de l'email *</Label>
<Textarea
id="edit-contenu"
value={formData.contenu}
onChange={(e) => setFormData({ ...formData, contenu: e.target.value })}
placeholder="Utilisez {{nom_apprenant}}, {{titre_formation}}, {{date_debut}}, {{lieu}} comme variables"
rows={8}
/>
<p className="text-sm text-muted-foreground">
Variables disponibles : {"{"}{"{"} nom_apprenant {"}"}{"}"}, {"{"}{"{"} titre_formation {"}"}{"}"}, {"{"}{"{"} date_debut {"}"}{"}"}, {"{"}{"{"} lieu {"}"}{"}"}, {"{"}{"{"} formateur {"}"}{"}"}
</p>
</div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="edit-actif" id="edit-actif"

View File

@@ -0,0 +1,12 @@
CREATE TABLE `rappels` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`templateType` varchar(50) NOT NULL,
`joursAvant` int NOT NULL,
`heureEnvoi` varchar(5) NOT NULL DEFAULT '09:00',
`actif` boolean NOT NULL DEFAULT true,
`derniereExecution` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `rappels_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1764329348207, "when": 1764329348207,
"tag": "0016_romantic_sinister_six", "tag": "0016_romantic_sinister_six",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1764540514199,
"tag": "0017_unique_skrulls",
"breakpoints": true
} }
] ]
} }

View File

@@ -263,3 +263,28 @@ export const alertes = mysqlTable("alertes", {
export type Alerte = typeof alertes.$inferSelect; export type Alerte = typeof alertes.$inferSelect;
export type InsertAlerte = typeof alertes.$inferInsert; export type InsertAlerte = typeof alertes.$inferInsert;
/**
* Table des rappels automatiques
* Stocke les règles d'envoi automatique de rappels avant les séquences
*/
export const rappels = mysqlTable("rappels", {
id: int("id").autoincrement().primaryKey(),
/** Nom du rappel */
nom: varchar("nom", { length: 255 }).notNull(),
/** Type de template à utiliser (rappel, rappelJ1) */
templateType: varchar("templateType", { length: 50 }).notNull(),
/** Nombre de jours avant la première date de la séquence */
joursAvant: int("joursAvant").notNull(),
/** Heure d'envoi (format HH:MM) */
heureEnvoi: varchar("heureEnvoi", { length: 5 }).default("09:00").notNull(),
/** Actif ou non */
actif: boolean("actif").default(true).notNull(),
/** Dernière exécution */
derniereExecution: timestamp("derniereExecution"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Rappel = typeof rappels.$inferSelect;
export type InsertRappel = typeof rappels.$inferInsert;

View File

@@ -26,7 +26,10 @@ import {
InsertEmailTemplate, InsertEmailTemplate,
formateurs, formateurs,
InsertFormateur, InsertFormateur,
Formateur Formateur,
rappels,
InsertRappel,
Rappel
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -816,3 +819,70 @@ export async function deleteFormateur(id: number) {
await db.delete(formateurs).where(eq(formateurs.id, id)); await db.delete(formateurs).where(eq(formateurs.id, id));
} }
// ==================== RAPPELS ====================
export async function createRappel(data: InsertRappel) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Construire un objet d'insertion explicite sans derniereExecution
const insertData: Partial<InsertRappel> = {
nom: data.nom,
templateType: data.templateType,
joursAvant: data.joursAvant,
heureEnvoi: data.heureEnvoi,
actif: data.actif,
};
const result = await db.insert(rappels).values(insertData as InsertRappel);
return result;
}
export async function getRappels() {
const db = await getDb();
if (!db) return [];
return await db.select().from(rappels).orderBy(rappels.joursAvant);
}
export async function getRappelById(id: number): Promise<Rappel | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(rappels).where(eq(rappels.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getActiveRappels(): Promise<Rappel[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(rappels).where(eq(rappels.actif, true));
}
export async function updateRappel(id: number, data: Partial<InsertRappel>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(rappels).set({
...data,
updatedAt: new Date(),
}).where(eq(rappels.id, id));
}
export async function deleteRappel(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(rappels).where(eq(rappels.id, id));
}
export async function updateRappelExecution(id: number) {
const db = await getDb();
if (!db) return;
await db.update(rappels).set({
derniereExecution: new Date(),
}).where(eq(rappels.id, id));
}

View File

@@ -829,15 +829,15 @@ export const appRouter = router({
// ===== GESTION DES RAPPELS ===== // ===== GESTION DES RAPPELS =====
rappels: router({ rappels: router({
list: adminProcedure.query(async () => { list: adminProcedure.query(async () => {
return db.getAllRappels(); return db.getRappels();
}), }),
create: adminProcedure create: adminProcedure
.input(z.object({ .input(z.object({
nom: z.string(), nom: z.string(),
templateType: z.string(),
joursAvant: z.number(), joursAvant: z.number(),
sujet: z.string(), heureEnvoi: z.string().default("09:00"),
contenu: z.string(),
actif: z.boolean(), actif: z.boolean(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -847,14 +847,16 @@ export const appRouter = router({
update: adminProcedure update: adminProcedure
.input(z.object({ .input(z.object({
id: z.number(), id: z.number(),
nom: z.string(), nom: z.string().optional(),
joursAvant: z.number(), templateType: z.string().optional(),
sujet: z.string(), joursAvant: z.number().optional(),
contenu: z.string(), heureEnvoi: z.string().optional(),
actif: z.boolean(), actif: z.boolean().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return db.updateRappel(input); const { id, ...data } = input;
await db.updateRappel(id, data);
return { success: true };
}), }),
delete: adminProcedure delete: adminProcedure
@@ -867,7 +869,7 @@ export const appRouter = router({
toggleActif: adminProcedure toggleActif: adminProcedure
.input(z.object({ id: z.number(), actif: z.boolean() })) .input(z.object({ id: z.number(), actif: z.boolean() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db.updateRappel({ id: input.id, actif: input.actif }); await db.updateRappel(input.id, { actif: input.actif });
return { success: true }; return { success: true };
}), }),
}), }),

34
todo.md
View File

@@ -869,3 +869,37 @@
- [x] Analyser le code d'envoi d'emails pour identifier pourquoi le mode simulation persiste - [x] Analyser le code d'envoi d'emails pour identifier pourquoi le mode simulation persiste
- [x] Corriger le double envoi (email réel + notification simulée) - [x] Corriger le double envoi (email réel + notification simulée)
- [ ] Tester l'envoi réel d'emails après correction - [ ] Tester l'envoi réel d'emails après correction
## Bug Rappels - Types J-7 et J-1 manquants
- [x] Analyser la page des rappels pour identifier pourquoi les types J-7 et J-1 n'apparaissent pas
- [x] Ajouter les types J-7 et J-1 dans l'interface de gestion des rappels
- [ ] Tester la création de rappels J-7 et J-1
## Correction template email rappel J-1
- [x] Ajouter le template de rappel J-1 dans la page de gestion des templates d'emails
## Correction template email J-7
- [x] Analyser le template J-7 dans la page des templates d'emails
- [x] Analyser l'aperçu J-7 depuis les séquences
- [x] Identifier les différences entre les deux
- [x] Corriger le template pour qu'il corresponde à l'aperçu des séquences
## Amélioration template J-7
- [x] Modifier le template J-7 pour afficher toutes les dates de la séquence au lieu d'une seule date
## Bug SMTP - Mode simulation
- [x] Analyser le code d'envoi d'emails pour identifier pourquoi le mode simulation persiste
- [x] Corriger le double envoi (email réel + notification simulée)
- [x] Tester l'envoi réel d'emails après correction
## Bug Rappels - Types J-7 et J-1 manquants
- [x] Analyser la page des rappels pour identifier pourquoi les types J-7 et J-1 n'apparaissent pas
- [x] Ajouter les types J-7 et J-1 dans l'interface de gestion des rappels
- [ ] Corriger le bug d'insertion SQL (erreur sur derniereExecution)
- [ ] Tester la création de rappels J-7 et J-1