Checkpoint: Checkpoint complet avec toutes les fonctionnalités développées
✅ **Fonctionnalités incluses**
**1. Gestion des formateurs**
- Table formateurs dans la base de données
- Composant FormateurCombobox avec autocomplétion
- Ajout rapide de nouveaux formateurs depuis le formulaire
- Champ formateur dans les séquences (création et modification)
- Affichage du formateur dans le tableau et les emails
**2. Barre de progression colorée**
- Composant CapacityProgressBar avec code couleur automatique
- Vert (<70%), Orange (70-90%), Rouge (>90%)
- Affichage "nbInscrits / capacité" + pourcentage
- Intégré dans le tableau des séquences
**3. Vue calendrier mensuel**
- Page AdminCalendrier avec navigation mois précédent/suivant
- Affichage des séquences sur leurs dates respectives
- Dialog de détails au clic sur une séquence
- Lien "Calendrier" dans le menu de navigation
**4. Système de rappels automatiques**
- Page AdminRappels pour configurer les rappels (J-15, J-7, J-1)
- Templates d'emails personnalisables avec variables dynamiques
- Activation/désactivation des rappels
- Infrastructure prête pour l'envoi automatique futur
**5. Améliorations diverses**
- Public cible "Tous" ajouté dans les options
- Formulaires de séquences réorganisés (pas de chevauchement)
- Affichage du formateur dans les emails d'invitation
- Colonne formateur dans le tableau des séquences
This commit is contained in:
39
client/src/components/CapacityProgressBar.tsx
Normal file
39
client/src/components/CapacityProgressBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CapacityProgressBarProps {
|
||||
nbInscrits: number;
|
||||
capaciteMax: number;
|
||||
}
|
||||
|
||||
export default function CapacityProgressBar({
|
||||
nbInscrits,
|
||||
capaciteMax,
|
||||
}: CapacityProgressBarProps) {
|
||||
const percentage = capaciteMax > 0 ? Math.round((nbInscrits / capaciteMax) * 100) : 0;
|
||||
|
||||
// Déterminer la couleur selon le taux de remplissage
|
||||
const getColor = () => {
|
||||
if (percentage < 70) return "bg-green-500";
|
||||
if (percentage < 90) return "bg-orange-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{nbInscrits} / {capaciteMax}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-2"
|
||||
indicatorClassName={cn("transition-all", getColor())}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
client/src/pages/AdminCalendrier.tsx
Normal file
254
client/src/pages/AdminCalendrier.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, parseISO, addMonths, subMonths } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
export default function AdminCalendrier() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedSequence, setSelectedSequence] = useState<any>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
|
||||
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
// Obtenir le premier jour de la semaine (lundi = 0)
|
||||
const firstDayOfWeek = (monthStart.getDay() + 6) % 7;
|
||||
|
||||
// Créer un tableau avec les jours vides au début
|
||||
const calendarDays = [
|
||||
...Array(firstDayOfWeek).fill(null),
|
||||
...daysInMonth,
|
||||
];
|
||||
|
||||
const getSequencesForDay = (day: Date) => {
|
||||
if (!sequences) return [];
|
||||
return sequences.filter((seq) => {
|
||||
if (!seq.datesFormation || seq.datesFormation.length === 0) return false;
|
||||
return seq.datesFormation.some((dateForm: any) => {
|
||||
const dateDebut = parseISO(dateForm.dateDebut);
|
||||
return isSameDay(dateDebut, day);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentDate(subMonths(currentDate, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentDate(addMonths(currentDate, 1));
|
||||
};
|
||||
|
||||
const handleSequenceClick = (sequence: any) => {
|
||||
setSelectedSequence(sequence);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const getPublicCibleLabel = (publicCible: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
directeurs: "Directeurs",
|
||||
chefs_service: "Chefs de service",
|
||||
tous: "Tous",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[publicCible] || publicCible;
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const badges: Record<string, string> = {
|
||||
ouverte: "bg-green-100 text-green-800",
|
||||
fermee: "bg-red-100 text-red-800",
|
||||
annulee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return badges[statut] || "bg-blue-100 text-blue-800";
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Calendrier des séquences</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vue mensuelle de toutes les séquences planifiées
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{format(currentDate, "MMMM yyyy", { locale: fr })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Cliquez sur une séquence pour voir les détails
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={handlePreviousMonth}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
>
|
||||
Aujourd'hui
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleNextMonth}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Chargement...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{/* En-têtes des jours */}
|
||||
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center font-semibold text-sm text-muted-foreground py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Jours du calendrier */}
|
||||
{calendarDays.map((day, index) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${index}`} className="min-h-[100px]" />;
|
||||
}
|
||||
|
||||
const daySequences = getSequencesForDay(day);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`min-h-[100px] border rounded-lg p-2 ${
|
||||
!isSameMonth(day, currentDate)
|
||||
? "bg-muted/50"
|
||||
: isToday
|
||||
? "bg-blue-50 border-blue-300"
|
||||
: "bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-sm mb-1">
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{daySequences.map((seq) => (
|
||||
<button
|
||||
key={seq.id}
|
||||
onClick={() => handleSequenceClick(seq)}
|
||||
className="w-full text-left text-xs p-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-800 truncate"
|
||||
>
|
||||
{seq.formation?.titre || "Sans titre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog de détails */}
|
||||
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la séquence</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informations complètes sur la séquence sélectionnée
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedSequence && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{selectedSequence.formation?.titre || "Sans titre"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedSequence.formation?.description || "Aucune description"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
|
||||
<p className="text-sm">{selectedSequence.lieu}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{getPublicCibleLabel(selectedSequence.publicCible)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
|
||||
<p className="text-sm">
|
||||
{selectedSequence.nbInscrits || 0} / {selectedSequence.capaciteMax}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Statut</p>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(selectedSequence.statut)}`}>
|
||||
{selectedSequence.statut}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSequence.formateur && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Formateur</p>
|
||||
<p className="text-sm">{selectedSequence.formateur}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedSequence.dateBloquage && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Date de blocage</p>
|
||||
<p className="text-sm">
|
||||
{format(parseISO(selectedSequence.dateBloquage), "dd/MM/yyyy", { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSequence.datesFormation && selectedSequence.datesFormation.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
|
||||
<div className="space-y-1">
|
||||
{selectedSequence.datesFormation.map((dateForm: any, index: number) => (
|
||||
<div key={index} className="text-sm bg-muted p-2 rounded">
|
||||
Du {format(parseISO(dateForm.dateDebut), "dd/MM/yyyy", { locale: fr })} au{" "}
|
||||
{format(parseISO(dateForm.dateFin), "dd/MM/yyyy", { locale: fr })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
366
client/src/pages/AdminRappels.tsx
Normal file
366
client/src/pages/AdminRappels.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useState } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Bell, Edit, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminRappels() {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editingRappel, setEditingRappel] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
joursAvant: 0,
|
||||
sujet: "",
|
||||
contenu: "",
|
||||
actif: true,
|
||||
});
|
||||
|
||||
const { data: rappels, refetch } = trpc.rappels.list.useQuery();
|
||||
const createMutation = trpc.rappels.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Rappel créé avec succès");
|
||||
refetch();
|
||||
setIsCreateOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.rappels.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Rappel modifié avec succès");
|
||||
refetch();
|
||||
setIsEditOpen(false);
|
||||
setEditingRappel(null);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.rappels.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Rappel supprimé avec succès");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
nom: "",
|
||||
joursAvant: 0,
|
||||
sujet: "",
|
||||
contenu: "",
|
||||
actif: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.nom || !formData.sujet || !formData.contenu) {
|
||||
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleEdit = (rappel: any) => {
|
||||
setEditingRappel(rappel);
|
||||
setFormData({
|
||||
nom: rappel.nom,
|
||||
joursAvant: rappel.joursAvant,
|
||||
sujet: rappel.sujet,
|
||||
contenu: rappel.contenu,
|
||||
actif: rappel.actif,
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!editingRappel) return;
|
||||
if (!formData.nom || !formData.sujet || !formData.contenu) {
|
||||
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
id: editingRappel.id,
|
||||
...formData,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer ce rappel ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Rappels automatiques</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configurez les rappels envoyés automatiquement avant les séquences
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouveau rappel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Liste des rappels configurés
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Les rappels actifs seront envoyés automatiquement aux apprenants inscrits
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Délai</TableHead>
|
||||
<TableHead>Sujet</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rappels && rappels.length > 0 ? (
|
||||
rappels.map((rappel) => (
|
||||
<TableRow key={rappel.id}>
|
||||
<TableCell className="font-medium">{rappel.nom}</TableCell>
|
||||
<TableCell>J-{rappel.joursAvant}</TableCell>
|
||||
<TableCell>{rappel.sujet}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
rappel.actif
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{rappel.actif ? "Actif" : "Inactif"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(rappel)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(rappel.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Aucun rappel configuré
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog de création */}
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un nouveau rappel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configurez un rappel automatique à envoyer avant les séquences
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom du rappel *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Ex: Rappel J-15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="joursAvant">Nombre de jours avant la séquence *</Label>
|
||||
<Input
|
||||
id="joursAvant"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.joursAvant}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
placeholder="Ex: 15"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Le rappel sera envoyé ce nombre de jours avant le début de la séquence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sujet">Sujet de l'email *</Label>
|
||||
<Input
|
||||
id="sujet"
|
||||
value={formData.sujet}
|
||||
onChange={(e) => setFormData({ ...formData, sujet: e.target.value })}
|
||||
placeholder="Ex: Rappel - Formation dans 15 jours"
|
||||
/>
|
||||
</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">
|
||||
Variables disponibles : {"{"}{"{"} nom_apprenant {"}"}{"}"}, {"{"}{"{"} titre_formation {"}"}{"}"}, {"{"}{"{"} date_debut {"}"}{"}"}, {"{"}{"{"} lieu {"}"}{"}"}, {"{"}{"{"} formateur {"}"}{"}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="actif"
|
||||
checked={formData.actif}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, actif: checked })}
|
||||
/>
|
||||
<Label htmlFor="actif">Rappel actif</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de modification */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le rappel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez la configuration du rappel automatique
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-nom">Nom du rappel *</Label>
|
||||
<Input
|
||||
id="edit-nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Ex: Rappel J-15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-joursAvant">Nombre de jours avant la séquence *</Label>
|
||||
<Input
|
||||
id="edit-joursAvant"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.joursAvant}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
placeholder="Ex: 15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sujet">Sujet de l'email *</Label>
|
||||
<Input
|
||||
id="edit-sujet"
|
||||
value={formData.sujet}
|
||||
onChange={(e) => setFormData({ ...formData, sujet: e.target.value })}
|
||||
placeholder="Ex: Rappel - Formation dans 15 jours"
|
||||
/>
|
||||
</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">
|
||||
<Switch
|
||||
id="edit-actif"
|
||||
checked={formData.actif}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, actif: checked })}
|
||||
/>
|
||||
<Label htmlFor="edit-actif">Rappel actif</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Modification..." : "Modifier"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Calendar, Edit, Eye, Plus, Trash2, X } from "lucide-react";
|
||||
import FormateurCombobox from "@/components/FormateurCombobox";
|
||||
import CapacityProgressBar from "@/components/CapacityProgressBar";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation } from "wouter";
|
||||
import { format } from "date-fns";
|
||||
@@ -644,7 +645,12 @@ export default function AdminSequences() {
|
||||
{getPublicCibleLabel(sequence.publicCible)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{sequence.capaciteMax}</TableCell>
|
||||
<TableCell>
|
||||
<CapacityProgressBar
|
||||
nbInscrits={sequence.nbInscrits || 0}
|
||||
capaciteMax={sequence.capaciteMax}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(sequence.statut)}`}>
|
||||
{sequence.statut}
|
||||
|
||||
Reference in New Issue
Block a user