Checkpoint: Ajout d'une interface d'administration complète pour personnaliser les templates d'emails avec prévisualisation en temps réel, stockage en base de données et intégration avec le service d'envoi
This commit is contained in:
9
.manus/db/db-query-1763417332805.json
Normal file
9
.manus/db/db-query-1763417332805.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"query": "SELECT * FROM emailTemplates;",
|
||||||
|
"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 emailTemplates;",
|
||||||
|
"rows": [],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 57
|
||||||
|
}
|
||||||
9
.manus/db/db-query-1763417422542.json
Normal file
9
.manus/db/db-query-1763417422542.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"query": "INSERT INTO emailTemplates (type, name, logoUrl, primaryColor, headerBgColor, headerTextColor, headerTitle, footerText, active) VALUES \n('inscription', 'Confirmation d''inscription', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('teaser', 'Email teaser', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('rappel', 'Rappel J-7', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('reset_password', 'Réinitialisation de mot de passe', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1);",
|
||||||
|
"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 INSERT INTO emailTemplates (type, name, logoUrl, primaryColor, headerBgColor, headerTextColor, headerTitle, footerText, active) VALUES \n('inscription', 'Confirmation d''inscription', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('teaser', 'Email teaser', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('rappel', 'Rappel J-7', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('reset_password', 'Réinitialisation de mot de passe', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1);",
|
||||||
|
"rows": [],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 64
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import AdminSequences from "./pages/AdminSequences";
|
|||||||
import AdminApprenants from "./pages/AdminApprenants";
|
import AdminApprenants from "./pages/AdminApprenants";
|
||||||
import AdminSequenceInscrits from "./pages/AdminSequenceInscrits";
|
import AdminSequenceInscrits from "./pages/AdminSequenceInscrits";
|
||||||
import AdminUsers from "./pages/AdminUsers";
|
import AdminUsers from "./pages/AdminUsers";
|
||||||
|
import AdminEmailTemplates from "./pages/AdminEmailTemplates";
|
||||||
import Inscription from "./pages/Inscription";
|
import Inscription from "./pages/Inscription";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
@@ -26,6 +27,7 @@ function Router() {
|
|||||||
<Route path={"/admin/apprenants"} component={AdminApprenants} />
|
<Route path={"/admin/apprenants"} component={AdminApprenants} />
|
||||||
<Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} />
|
<Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} />
|
||||||
<Route path={"/admin/users"} component={AdminUsers} />
|
<Route path={"/admin/users"} component={AdminUsers} />
|
||||||
|
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog } from "lucide-react";
|
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog, Mail } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -33,6 +33,7 @@ const menuItems = [
|
|||||||
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
|
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
|
||||||
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
||||||
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
||||||
|
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
|
||||||
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
|
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
364
client/src/pages/AdminEmailTemplates.tsx
Normal file
364
client/src/pages/AdminEmailTemplates.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Mail, Eye, Save, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminEmailTemplates() {
|
||||||
|
const [selectedType, setSelectedType] = useState<string>("inscription");
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
type: "inscription",
|
||||||
|
name: "",
|
||||||
|
logoUrl: "",
|
||||||
|
primaryColor: "#2563eb",
|
||||||
|
headerBgColor: "#2563eb",
|
||||||
|
headerTextColor: "#ffffff",
|
||||||
|
headerTitle: "Formation Manager Itinova",
|
||||||
|
footerText: "",
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: templates, refetch } = trpc.emailTemplates.list.useQuery();
|
||||||
|
const { data: currentTemplate } = trpc.emailTemplates.getByType.useQuery(
|
||||||
|
{ type: selectedType },
|
||||||
|
{ enabled: !!selectedType }
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertMutation = trpc.emailTemplates.upsert.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Template enregistré avec succès");
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Erreur: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initMutation = trpc.emailTemplates.initializeDefaults.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Templates par défaut initialisés");
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Erreur: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charger le template sélectionné
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTemplate) {
|
||||||
|
setFormData({
|
||||||
|
type: currentTemplate.type,
|
||||||
|
name: currentTemplate.name,
|
||||||
|
logoUrl: currentTemplate.logoUrl || "",
|
||||||
|
primaryColor: currentTemplate.primaryColor,
|
||||||
|
headerBgColor: currentTemplate.headerBgColor,
|
||||||
|
headerTextColor: currentTemplate.headerTextColor,
|
||||||
|
headerTitle: currentTemplate.headerTitle,
|
||||||
|
footerText: currentTemplate.footerText || "",
|
||||||
|
active: currentTemplate.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentTemplate]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
upsertMutation.mutate({
|
||||||
|
...formData,
|
||||||
|
logoUrl: formData.logoUrl || null,
|
||||||
|
footerText: formData.footerText || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (currentTemplate) {
|
||||||
|
setFormData({
|
||||||
|
type: currentTemplate.type,
|
||||||
|
name: currentTemplate.name,
|
||||||
|
logoUrl: currentTemplate.logoUrl || "",
|
||||||
|
primaryColor: currentTemplate.primaryColor,
|
||||||
|
headerBgColor: currentTemplate.headerBgColor,
|
||||||
|
headerTextColor: currentTemplate.headerTextColor,
|
||||||
|
headerTitle: currentTemplate.headerTitle,
|
||||||
|
footerText: currentTemplate.footerText || "",
|
||||||
|
active: currentTemplate.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitDefaults = () => {
|
||||||
|
if (confirm("Initialiser les templates par défaut ? Cela ne modifiera pas les templates existants.")) {
|
||||||
|
initMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Générer la prévisualisation HTML
|
||||||
|
const generatePreview = () => {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||||
|
.header { background-color: ${formData.headerBgColor}; color: ${formData.headerTextColor}; padding: 30px 20px; text-align: center; }
|
||||||
|
.header h1 { margin: 0; font-size: 24px; }
|
||||||
|
${formData.logoUrl ? `.header img { max-width: 150px; margin-bottom: 15px; }` : ''}
|
||||||
|
.content { background-color: #f9fafb; padding: 30px 20px; }
|
||||||
|
.content h2 { color: ${formData.primaryColor}; margin-top: 0; }
|
||||||
|
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: ${formData.primaryColor}; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||||
|
.info-box { background-color: #dbeafe; border-left: 4px solid ${formData.primaryColor}; padding: 15px; margin: 15px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
${formData.logoUrl ? `<img src="${formData.logoUrl}" alt="Logo" />` : ''}
|
||||||
|
<h1>${formData.headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Exemple de contenu</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Ceci est un exemple de prévisualisation de votre template d'email personnalisé.</p>
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Information importante</strong></p>
|
||||||
|
<p>Ce bloc utilise la couleur principale que vous avez choisie.</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="#" class="button">Bouton d'action</a>
|
||||||
|
</div>
|
||||||
|
<p>Cordialement,<br/>L'équipe Formation Manager Itinova</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>${formData.footerText || 'Texte du pied de page'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateTypes = [
|
||||||
|
{ value: "inscription", label: "Confirmation d'inscription" },
|
||||||
|
{ value: "teaser", label: "Email teaser" },
|
||||||
|
{ value: "rappel", label: "Rappel J-7" },
|
||||||
|
{ value: "reset_password", label: "Réinitialisation de mot de passe" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Templates d'emails</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Personnalisez l'apparence de vos emails automatiques
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!templates || templates.length === 0) && (
|
||||||
|
<Card className="mb-6 border-yellow-200 bg-yellow-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Initialisation requise
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Aucun template trouvé. Initialisez les templates par défaut pour commencer.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={handleInitDefaults} disabled={initMutation.isPending}>
|
||||||
|
{initMutation.isPending ? "Initialisation..." : "Initialiser les templates par défaut"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Éditeur */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Éditeur de template</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Modifiez les paramètres du template sélectionné
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="templateType">Type de template</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedType(value);
|
||||||
|
setFormData((prev) => ({ ...prev, type: value }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="templateType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templateTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Nom du template</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Ex: Confirmation d'inscription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="logoUrl">URL du logo (optionnel)</Label>
|
||||||
|
<Input
|
||||||
|
id="logoUrl"
|
||||||
|
value={formData.logoUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, logoUrl: e.target.value })}
|
||||||
|
placeholder="https://exemple.com/logo.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="headerBgColor">Couleur fond en-tête</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="headerBgColor"
|
||||||
|
type="color"
|
||||||
|
value={formData.headerBgColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
|
||||||
|
className="w-16 h-10 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={formData.headerBgColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
|
||||||
|
placeholder="#2563eb"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="headerTextColor">Couleur texte en-tête</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="headerTextColor"
|
||||||
|
type="color"
|
||||||
|
value={formData.headerTextColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
|
||||||
|
className="w-16 h-10 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={formData.headerTextColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
|
||||||
|
placeholder="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="primaryColor">Couleur principale</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="primaryColor"
|
||||||
|
type="color"
|
||||||
|
value={formData.primaryColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
|
||||||
|
className="w-16 h-10 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={formData.primaryColor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
|
||||||
|
placeholder="#2563eb"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Utilisée pour les boutons et les accents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="headerTitle">Titre de l'en-tête</Label>
|
||||||
|
<Input
|
||||||
|
id="headerTitle"
|
||||||
|
value={formData.headerTitle}
|
||||||
|
onChange={(e) => setFormData({ ...formData, headerTitle: e.target.value })}
|
||||||
|
placeholder="Formation Manager Itinova"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="footerText">Texte du pied de page</Label>
|
||||||
|
<Textarea
|
||||||
|
id="footerText"
|
||||||
|
value={formData.footerText}
|
||||||
|
onChange={(e) => setFormData({ ...formData, footerText: e.target.value })}
|
||||||
|
placeholder="Cet email a été envoyé automatiquement..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button onClick={handleSave} disabled={upsertMutation.isPending} className="flex-1">
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{upsertMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset} variant="outline">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Prévisualisation */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
Prévisualisation en temps réel
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Aperçu de votre template avec les paramètres actuels
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-lg overflow-hidden bg-gray-50">
|
||||||
|
<iframe
|
||||||
|
srcDoc={generatePreview()}
|
||||||
|
className="w-full h-[600px] border-0"
|
||||||
|
title="Prévisualisation du template"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
drizzle/0008_jittery_fenris.sql
Normal file
16
drizzle/0008_jittery_fenris.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE `emailTemplates` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`type` varchar(50) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`logoUrl` text,
|
||||||
|
`primaryColor` varchar(7) NOT NULL DEFAULT '#2563eb',
|
||||||
|
`headerBgColor` varchar(7) NOT NULL DEFAULT '#2563eb',
|
||||||
|
`headerTextColor` varchar(7) NOT NULL DEFAULT '#ffffff',
|
||||||
|
`headerTitle` varchar(255) NOT NULL DEFAULT 'Formation Manager Itinova',
|
||||||
|
`footerText` text,
|
||||||
|
`active` boolean NOT NULL DEFAULT true,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `emailTemplates_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `emailTemplates_type_unique` UNIQUE(`type`)
|
||||||
|
);
|
||||||
684
drizzle/meta/0008_snapshot.json
Normal file
684
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "73d62a50-d4a5-41dd-b5a2-4d2d72b7f119",
|
||||||
|
"prevId": "44a452bc-0f54-464a-98ae-043474047a69",
|
||||||
|
"tables": {
|
||||||
|
"apprenants": {
|
||||||
|
"name": "apprenants",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"nom": {
|
||||||
|
"name": "nom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prenom": {
|
||||||
|
"name": "prenom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"codeEtablissement": {
|
||||||
|
"name": "codeEtablissement",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fonction": {
|
||||||
|
"name": "fonction",
|
||||||
|
"type": "enum('directeur','chef_service','autre')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"apprenants_id": {
|
||||||
|
"name": "apprenants_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"apprenants_email_unique": {
|
||||||
|
"name": "apprenants_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"datesFormation": {
|
||||||
|
"name": "datesFormation",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"sequenceId": {
|
||||||
|
"name": "sequenceId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dateDebut": {
|
||||||
|
"name": "dateDebut",
|
||||||
|
"type": "datetime",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dateFin": {
|
||||||
|
"name": "dateFin",
|
||||||
|
"type": "datetime",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ordre": {
|
||||||
|
"name": "ordre",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"datesFormation_id": {
|
||||||
|
"name": "datesFormation_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"emailTemplates": {
|
||||||
|
"name": "emailTemplates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"logoUrl": {
|
||||||
|
"name": "logoUrl",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"primaryColor": {
|
||||||
|
"name": "primaryColor",
|
||||||
|
"type": "varchar(7)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'#2563eb'"
|
||||||
|
},
|
||||||
|
"headerBgColor": {
|
||||||
|
"name": "headerBgColor",
|
||||||
|
"type": "varchar(7)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'#2563eb'"
|
||||||
|
},
|
||||||
|
"headerTextColor": {
|
||||||
|
"name": "headerTextColor",
|
||||||
|
"type": "varchar(7)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'#ffffff'"
|
||||||
|
},
|
||||||
|
"headerTitle": {
|
||||||
|
"name": "headerTitle",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Formation Manager Itinova'"
|
||||||
|
},
|
||||||
|
"footerText": {
|
||||||
|
"name": "footerText",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"emailTemplates_id": {
|
||||||
|
"name": "emailTemplates_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"emailTemplates_type_unique": {
|
||||||
|
"name": "emailTemplates_type_unique",
|
||||||
|
"columns": [
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"formations": {
|
||||||
|
"name": "formations",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"nom": {
|
||||||
|
"name": "nom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lienUnique": {
|
||||||
|
"name": "lienUnique",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"actif": {
|
||||||
|
"name": "actif",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"formations_id": {
|
||||||
|
"name": "formations_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"formations_lienUnique_unique": {
|
||||||
|
"name": "formations_lienUnique_unique",
|
||||||
|
"columns": [
|
||||||
|
"lienUnique"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"inscriptions": {
|
||||||
|
"name": "inscriptions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"apprenantId": {
|
||||||
|
"name": "apprenantId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sequenceId": {
|
||||||
|
"name": "sequenceId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"statut": {
|
||||||
|
"name": "statut",
|
||||||
|
"type": "enum('confirmee','liste_attente','annulee')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dateInscription": {
|
||||||
|
"name": "dateInscription",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"inscriptions_id": {
|
||||||
|
"name": "inscriptions_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"passwordResetTokens": {
|
||||||
|
"name": "passwordResetTokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"name": "used",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"passwordResetTokens_id": {
|
||||||
|
"name": "passwordResetTokens_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"passwordResetTokens_token_unique": {
|
||||||
|
"name": "passwordResetTokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"sequences": {
|
||||||
|
"name": "sequences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"formationId": {
|
||||||
|
"name": "formationId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"nom": {
|
||||||
|
"name": "nom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lieu": {
|
||||||
|
"name": "lieu",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"publicCible": {
|
||||||
|
"name": "publicCible",
|
||||||
|
"type": "enum('directeur','chef_service','autre')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"capaciteMax": {
|
||||||
|
"name": "capaciteMax",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 12
|
||||||
|
},
|
||||||
|
"dateBlocage": {
|
||||||
|
"name": "dateBlocage",
|
||||||
|
"type": "datetime",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"statut": {
|
||||||
|
"name": "statut",
|
||||||
|
"type": "enum('ouverte','bloquee','terminee')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'ouverte'"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"sequences_id": {
|
||||||
|
"name": "sequences_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"openId": {
|
||||||
|
"name": "openId",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"loginMethod": {
|
||||||
|
"name": "loginMethod",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "enum('user','admin')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"name": "isActive",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"lastSignedIn": {
|
||||||
|
"name": "lastSignedIn",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"users_id": {
|
||||||
|
"name": "users_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_openId_unique": {
|
||||||
|
"name": "users_openId_unique",
|
||||||
|
"columns": [
|
||||||
|
"openId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"tables": {},
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1763414014386,
|
"when": 1763414014386,
|
||||||
"tag": "0007_awesome_genesis",
|
"tag": "0007_awesome_genesis",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1763416801277,
|
||||||
|
"tag": "0008_jittery_fenris",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -141,3 +141,33 @@ export const passwordResetTokens = mysqlTable("passwordResetTokens", {
|
|||||||
|
|
||||||
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
|
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
|
||||||
export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
|
export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table des templates d'emails personnalisables
|
||||||
|
*/
|
||||||
|
export const emailTemplates = mysqlTable("emailTemplates", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
/** Type de template (inscription, teaser, rappel, reset_password) */
|
||||||
|
type: varchar("type", { length: 50 }).notNull().unique(),
|
||||||
|
/** Nom affiché du template */
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
/** URL du logo (peut être vide pour utiliser le logo par défaut) */
|
||||||
|
logoUrl: text("logoUrl"),
|
||||||
|
/** Couleur principale (hex) */
|
||||||
|
primaryColor: varchar("primaryColor", { length: 7 }).default("#2563eb").notNull(),
|
||||||
|
/** Couleur de fond de l'en-tête (hex) */
|
||||||
|
headerBgColor: varchar("headerBgColor", { length: 7 }).default("#2563eb").notNull(),
|
||||||
|
/** Couleur du texte de l'en-tête (hex) */
|
||||||
|
headerTextColor: varchar("headerTextColor", { length: 7 }).default("#ffffff").notNull(),
|
||||||
|
/** Titre de l'en-tête */
|
||||||
|
headerTitle: varchar("headerTitle", { length: 255 }).default("Formation Manager Itinova").notNull(),
|
||||||
|
/** Texte du pied de page */
|
||||||
|
footerText: text("footerText"),
|
||||||
|
/** Actif ou non */
|
||||||
|
active: boolean("active").default(true).notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||||
|
export type InsertEmailTemplate = typeof emailTemplates.$inferInsert;
|
||||||
|
|||||||
137
server/db.ts
137
server/db.ts
@@ -18,7 +18,10 @@ import {
|
|||||||
Sequence,
|
Sequence,
|
||||||
DateFormation,
|
DateFormation,
|
||||||
Apprenant,
|
Apprenant,
|
||||||
Formation
|
Formation,
|
||||||
|
emailTemplates,
|
||||||
|
EmailTemplate,
|
||||||
|
InsertEmailTemplate
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
@@ -467,3 +470,135 @@ export async function deleteExpiredTokens() {
|
|||||||
await db.delete(passwordResetTokens)
|
await db.delete(passwordResetTokens)
|
||||||
.where(sql`${passwordResetTokens.expiresAt} < ${now}`);
|
.where(sql`${passwordResetTokens.expiresAt} < ${now}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Email Templates Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère tous les templates d'emails
|
||||||
|
*/
|
||||||
|
export async function getAllEmailTemplates() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
|
||||||
|
return await db.select().from(emailTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un template par son type
|
||||||
|
*/
|
||||||
|
export async function getEmailTemplateByType(type: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
|
||||||
|
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
|
||||||
|
return results.length > 0 ? results[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée ou met à jour un template d'email
|
||||||
|
*/
|
||||||
|
export async function upsertEmailTemplate(template: InsertEmailTemplate) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
|
const existing = await getEmailTemplateByType(template.type);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Mise à jour
|
||||||
|
await db.update(emailTemplates)
|
||||||
|
.set({
|
||||||
|
name: template.name,
|
||||||
|
logoUrl: template.logoUrl,
|
||||||
|
primaryColor: template.primaryColor,
|
||||||
|
headerBgColor: template.headerBgColor,
|
||||||
|
headerTextColor: template.headerTextColor,
|
||||||
|
headerTitle: template.headerTitle,
|
||||||
|
footerText: template.footerText,
|
||||||
|
active: template.active,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(emailTemplates.type, template.type));
|
||||||
|
|
||||||
|
return await getEmailTemplateByType(template.type);
|
||||||
|
} else {
|
||||||
|
// Création
|
||||||
|
await db.insert(emailTemplates).values(template);
|
||||||
|
return await getEmailTemplateByType(template.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un template d'email
|
||||||
|
*/
|
||||||
|
export async function deleteEmailTemplate(type: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
|
await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise les templates par défaut si la table est vide
|
||||||
|
*/
|
||||||
|
export async function initializeDefaultEmailTemplates() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const existing = await getAllEmailTemplates();
|
||||||
|
if (existing.length > 0) return; // Déjà initialisé
|
||||||
|
|
||||||
|
const defaultTemplates: InsertEmailTemplate[] = [
|
||||||
|
{
|
||||||
|
type: "inscription",
|
||||||
|
name: "Confirmation d'inscription",
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: "#2563eb",
|
||||||
|
headerBgColor: "#2563eb",
|
||||||
|
headerTextColor: "#ffffff",
|
||||||
|
headerTitle: "Formation Manager Itinova",
|
||||||
|
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "teaser",
|
||||||
|
name: "Email teaser",
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: "#2563eb",
|
||||||
|
headerBgColor: "#2563eb",
|
||||||
|
headerTextColor: "#ffffff",
|
||||||
|
headerTitle: "Formation Manager Itinova",
|
||||||
|
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rappel",
|
||||||
|
name: "Rappel J-7",
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: "#2563eb",
|
||||||
|
headerBgColor: "#2563eb",
|
||||||
|
headerTextColor: "#ffffff",
|
||||||
|
headerTitle: "Formation Manager Itinova",
|
||||||
|
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "reset_password",
|
||||||
|
name: "Réinitialisation de mot de passe",
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: "#2563eb",
|
||||||
|
headerBgColor: "#2563eb",
|
||||||
|
headerTextColor: "#ffffff",
|
||||||
|
headerTitle: "Formation Manager Itinova",
|
||||||
|
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const template of defaultTemplates) {
|
||||||
|
await db.insert(emailTemplates).values(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[DB] Templates d'emails par défaut initialisés");
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { generateFormationICS } from "./icsGenerator";
|
import { generateFormationICS } from "./icsGenerator";
|
||||||
import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender";
|
import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender";
|
||||||
|
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
|
||||||
|
|
||||||
interface EmailParams {
|
interface EmailParams {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -26,41 +27,10 @@ async function sendEmail(params: EmailParams): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template HTML de base pour les emails
|
* Template HTML de base pour les emails (avec template personnalisé)
|
||||||
*/
|
*/
|
||||||
function getEmailTemplate(content: string): string {
|
async function getEmailTemplate(content: string, templateType: string = 'inscription'): Promise<string> {
|
||||||
return `
|
return await generateEmailFromTemplate(templateType, content);
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
||||||
.header { background-color: #2563eb; color: white; padding: 20px; text-align: center; }
|
|
||||||
.content { background-color: #f9fafb; padding: 30px; }
|
|
||||||
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
|
||||||
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
|
||||||
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
|
|
||||||
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Formation Manager Itinova</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
${content}
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
|
|
||||||
<p>Pour toute question, veuillez contacter le service RH.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,7 +122,7 @@ export async function sendInscriptionConfirmation(params: {
|
|||||||
subject: isConfirmed
|
subject: isConfirmed
|
||||||
? `Confirmation d'inscription - ${params.formationNom}`
|
? `Confirmation d'inscription - ${params.formationNom}`
|
||||||
: `Liste d'attente - ${params.formationNom}`,
|
: `Liste d'attente - ${params.formationNom}`,
|
||||||
html: getEmailTemplate(content),
|
html: await getEmailTemplate(content, 'inscription'),
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -204,7 +174,7 @@ export async function sendTeaserEmail(params: {
|
|||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: params.apprenantEmail,
|
to: params.apprenantEmail,
|
||||||
subject: `Votre formation ${params.formationNom} approche !`,
|
subject: `Votre formation ${params.formationNom} approche !`,
|
||||||
html: getEmailTemplate(content),
|
html: await getEmailTemplate(content, 'teaser'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +233,7 @@ export async function sendRappelJ7Email(params: {
|
|||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: params.apprenantEmail,
|
to: params.apprenantEmail,
|
||||||
subject: `Rappel J-7 : ${params.formationNom}`,
|
subject: `Rappel J-7 : ${params.formationNom}`,
|
||||||
html: getEmailTemplate(content),
|
html: await getEmailTemplate(content, 'rappel'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +326,6 @@ export async function sendPasswordResetEmail(params: {
|
|||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: params.apprenantEmail,
|
to: params.apprenantEmail,
|
||||||
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
|
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
|
||||||
html: getEmailTemplate(content),
|
html: await getEmailTemplate(content, 'reset_password'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
161
server/emailTemplateGenerator.ts
Normal file
161
server/emailTemplateGenerator.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Générateur de templates d'emails personnalisés
|
||||||
|
* Utilise les templates stockés en base de données
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as db from "./db";
|
||||||
|
import { EmailTemplate } from "../drizzle/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le HTML d'un email en utilisant le template personnalisé
|
||||||
|
*/
|
||||||
|
export async function generateEmailFromTemplate(
|
||||||
|
templateType: string,
|
||||||
|
content: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Récupérer le template depuis la base de données
|
||||||
|
const template = await db.getEmailTemplateByType(templateType);
|
||||||
|
|
||||||
|
// Si pas de template trouvé, utiliser le template par défaut
|
||||||
|
if (!template) {
|
||||||
|
return getDefaultEmailTemplate(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildEmailHTML(template, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le HTML de l'email avec le template
|
||||||
|
*/
|
||||||
|
function buildEmailHTML(template: EmailTemplate, content: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: ${template.headerBgColor};
|
||||||
|
color: ${template.headerTextColor};
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
${template.logoUrl ? `
|
||||||
|
.header img {
|
||||||
|
max-width: 150px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}` : ''}
|
||||||
|
.content {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
.content h2 {
|
||||||
|
color: ${template.primaryColor};
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: ${template.primaryColor};
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
border-left: 4px solid ${template.primaryColor};
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
.date-item {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
${template.logoUrl ? `<img src="${template.logoUrl}" alt="Logo" />` : ''}
|
||||||
|
<h1>${template.headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>${template.footerText || 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template par défaut si aucun template personnalisé n'est trouvé
|
||||||
|
*/
|
||||||
|
function getDefaultEmailTemplate(content: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||||
|
.header { background-color: #2563eb; color: white; padding: 30px 20px; text-align: center; }
|
||||||
|
.header h1 { margin: 0; font-size: 24px; }
|
||||||
|
.content { background-color: #f9fafb; padding: 30px 20px; }
|
||||||
|
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||||
|
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
|
||||||
|
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Formation Manager Itinova</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
|
||||||
|
<p>Pour toute question, veuillez contacter le service RH.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
@@ -625,6 +625,47 @@ export const appRouter = router({
|
|||||||
return { success: true, message: "Email de réinitialisation envoyé" };
|
return { success: true, message: "Email de réinitialisation envoyé" };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ===== GESTION DES TEMPLATES D'EMAILS =====
|
||||||
|
emailTemplates: router({
|
||||||
|
list: adminProcedure.query(async () => {
|
||||||
|
return db.getAllEmailTemplates();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByType: adminProcedure
|
||||||
|
.input(z.object({ type: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return db.getEmailTemplateByType(input.type);
|
||||||
|
}),
|
||||||
|
|
||||||
|
upsert: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
type: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
logoUrl: z.string().nullable(),
|
||||||
|
primaryColor: z.string(),
|
||||||
|
headerBgColor: z.string(),
|
||||||
|
headerTextColor: z.string(),
|
||||||
|
headerTitle: z.string(),
|
||||||
|
footerText: z.string().nullable(),
|
||||||
|
active: z.boolean(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return db.upsertEmailTemplate(input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ type: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await db.deleteEmailTemplate(input.type);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
initializeDefaults: adminProcedure.mutation(async () => {
|
||||||
|
await db.initializeDefaultEmailTemplates();
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
11
todo.md
11
todo.md
@@ -216,3 +216,14 @@
|
|||||||
- [x] Rechercher l'API d'envoi d'emails disponible dans Manus
|
- [x] Rechercher l'API d'envoi d'emails disponible dans Manus
|
||||||
- [x] Intégrer un vrai service d'envoi d'emails (Resend)
|
- [x] Intégrer un vrai service d'envoi d'emails (Resend)
|
||||||
- [x] Créer la documentation de configuration EMAIL_SETUP.md
|
- [x] Créer la documentation de configuration EMAIL_SETUP.md
|
||||||
|
|
||||||
|
## Interface de personnalisation des templates d'emails
|
||||||
|
|
||||||
|
- [x] Créer la table email_templates dans le schéma
|
||||||
|
- [x] Créer les procédures backend pour CRUD des templates
|
||||||
|
- [x] Créer la page AdminEmailTemplates
|
||||||
|
- [x] Implémenter l'éditeur de template (logo, couleurs, textes)
|
||||||
|
- [x] Ajouter la prévisualisation en temps réel
|
||||||
|
- [x] Modifier le service d'envoi pour utiliser les templates personnalisés
|
||||||
|
- [x] Ajouter le lien dans le menu de navigation
|
||||||
|
- [x] Tester la personnalisation et l'envoi avec templates personnalisés
|
||||||
|
|||||||
Reference in New Issue
Block a user