From 4bf589dde371878454a2a970c64001a4c8096577 Mon Sep 17 00:00:00 2001 From: Manus Sandbox Date: Wed, 19 Nov 2025 09:59:37 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Trois=20am=C3=A9liorations=20maje?= =?UTF-8?q?ures=20pour=20la=20gestion=20des=20s=C3=A9quences=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ **1. Indicateur visuel de remplissage** - Composant CapacityProgressBar avec barre de progression colorée - Affichage "X / Y" avec pourcentage - Couleurs automatiques : vert (<70%), orange (70-90%), rouge (>90%) - Intégré dans la colonne capacité du tableau des séquences ✅ **2. Vue calendrier mensuel** - Nouvelle page AdminCalendrier accessible depuis le menu - Calendrier mensuel avec navigation (mois précédent/suivant, aujourd'hui) - Affichage des séquences sur leurs dates respectives - Dialog de détails au clic sur une séquence - Vue complète avec toutes les informations (lieu, formateur, capacité, dates) ✅ **3. Système de rappels automatiques personnalisables** - Tables rappels et rappelsEnvoyes créées - Procédures tRPC complètes (list, create, update, delete) - Page AdminRappels avec interface de gestion - Configuration de rappels multiples (J-15, J-7, J-1, etc.) - Templates d'emails personnalisables avec variables dynamiques - Activation/désactivation des rappels - Infrastructure prête pour l'envoi automatique (nécessite service de planification externe) Ces améliorations facilitent grandement la planification et le suivi des formations. --- .manus/db/db-query-1763564186793.json | 9 + client/src/App.tsx | 4 + client/src/components/CapacityProgressBar.tsx | 43 + client/src/components/DashboardLayout.tsx | 4 +- client/src/components/ui/progress.tsx | 9 +- client/src/pages/AdminCalendrier.tsx | 226 +++++ client/src/pages/AdminRappels.tsx | 318 ++++++ client/src/pages/AdminSequences.tsx | 8 +- drizzle/0018_breezy_thundra.sql | 18 + drizzle/meta/0018_snapshot.json | 958 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 35 + server/db.ts | 84 ++ server/routers.ts | 34 + todo.md | 25 + 15 files changed, 1778 insertions(+), 4 deletions(-) create mode 100644 .manus/db/db-query-1763564186793.json create mode 100644 client/src/components/CapacityProgressBar.tsx create mode 100644 client/src/pages/AdminCalendrier.tsx create mode 100644 client/src/pages/AdminRappels.tsx create mode 100644 drizzle/0018_breezy_thundra.sql create mode 100644 drizzle/meta/0018_snapshot.json diff --git a/.manus/db/db-query-1763564186793.json b/.manus/db/db-query-1763564186793.json new file mode 100644 index 0000000..0c8b7ac --- /dev/null +++ b/.manus/db/db-query-1763564186793.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE IF NOT EXISTS rappels (\n id INT AUTO_INCREMENT PRIMARY KEY,\n nom VARCHAR(255) NOT NULL,\n joursAvant INT NOT NULL,\n emailTemplate TEXT NOT NULL,\n actif BOOLEAN DEFAULT TRUE NOT NULL,\n createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS rappelsEnvoyes (\n id INT AUTO_INCREMENT PRIMARY KEY,\n rappelId INT NOT NULL,\n inscriptionId INT NOT NULL,\n dateEnvoi TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n INDEX idx_rappel_inscription (rappelId, inscriptionId)\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 PRIMARY KEY,\n nom VARCHAR(255) NOT NULL,\n joursAvant INT NOT NULL,\n emailTemplate TEXT NOT NULL,\n actif BOOLEAN DEFAULT TRUE NOT NULL,\n createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS rappelsEnvoyes (\n id INT AUTO_INCREMENT PRIMARY KEY,\n rappelId INT NOT NULL,\n inscriptionId INT NOT NULL,\n dateEnvoi TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n INDEX idx_rappel_inscription (rappelId, inscriptionId)\n);", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 354 +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index eeb8c86..1aa1b7d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,11 +9,13 @@ import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import Admin from "./pages/Admin"; import AdminFormations from "./pages/AdminFormations"; import AdminSequences from "./pages/AdminSequences"; +import AdminCalendrier from "./pages/AdminCalendrier"; import AdminApprenants from "./pages/AdminApprenants"; import AdminSequenceInscrits from "./pages/AdminSequenceInscrits"; import AdminUsers from "./pages/AdminUsers"; import AdminEmailTemplates from "./pages/AdminEmailTemplates"; import AdminEmailConfig from "./pages/AdminEmailConfig"; +import AdminRappels from "./pages/AdminRappels"; import Inscription from "./pages/Inscription"; function Router() { @@ -25,11 +27,13 @@ function Router() { + + {/* Final fallback route */} diff --git a/client/src/components/CapacityProgressBar.tsx b/client/src/components/CapacityProgressBar.tsx new file mode 100644 index 0000000..08351be --- /dev/null +++ b/client/src/components/CapacityProgressBar.tsx @@ -0,0 +1,43 @@ +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; + +interface CapacityProgressBarProps { + current: number; + max: number; + className?: string; +} + +export function CapacityProgressBar({ current, max, className }: CapacityProgressBarProps) { + const percentage = max > 0 ? (current / max) * 100 : 0; + + // Définir la couleur en fonction du taux de remplissage + const getColorClass = () => { + if (percentage >= 90) return "bg-red-500"; + if (percentage >= 70) return "bg-orange-500"; + return "bg-green-500"; + }; + + const getTextColorClass = () => { + if (percentage >= 90) return "text-red-700"; + if (percentage >= 70) return "text-orange-700"; + return "text-green-700"; + }; + + return ( +
+
+ + {current} / {max} + + + {Math.round(percentage)}% + +
+ +
+ ); +} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index c88a2a6..9c37712 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog, Mail, Settings } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, BarChart3, UserCog, Mail, Settings, Bell } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -31,10 +31,12 @@ const menuItems = [ { icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" }, { icon: GraduationCap, label: "Formations", path: "/admin/formations" }, { icon: Calendar, label: "Séquences", path: "/admin/sequences" }, + { icon: CalendarDays, label: "Calendrier", path: "/admin/calendrier" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" }, { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" }, { icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" }, + { icon: Bell, label: "Rappels automatiques", path: "/admin/rappels" }, { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, ]; diff --git a/client/src/components/ui/progress.tsx b/client/src/components/ui/progress.tsx index 4c9f4ab..b92a163 100644 --- a/client/src/components/ui/progress.tsx +++ b/client/src/components/ui/progress.tsx @@ -3,11 +3,16 @@ import * as ProgressPrimitive from "@radix-ui/react-progress"; import { cn } from "@/lib/utils"; +interface ProgressProps extends React.ComponentProps { + indicatorClassName?: string; +} + function Progress({ className, value, + indicatorClassName, ...props -}: React.ComponentProps) { +}: ProgressProps) { return ( diff --git a/client/src/pages/AdminCalendrier.tsx b/client/src/pages/AdminCalendrier.tsx new file mode 100644 index 0000000..504bc85 --- /dev/null +++ b/client/src/pages/AdminCalendrier.tsx @@ -0,0 +1,226 @@ +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 { trpc } from "@/lib/trpc"; +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths, startOfWeek, endOfWeek } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export default function AdminCalendrier() { + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedSequence, setSelectedSequence] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { data: sequences = [], isLoading } = trpc.sequences.list.useQuery(); + const { data: formations = [] } = trpc.formations.list.useQuery(); + + const getFormationName = (formationId: number) => { + const formation = formations.find(f => f.id === formationId); + return formation?.nom || "Formation inconnue"; + }; + + // Générer les jours du calendrier (incluant les jours du mois précédent/suivant pour remplir la grille) + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Commence le lundi + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); + + // Obtenir les séquences pour un jour donné + const getSequencesForDay = (day: Date) => { + return sequences.filter(sequence => { + return sequence.dates.some((date: any) => { + const dateDebut = new Date(date.dateDebut); + return isSameDay(dateDebut, day); + }); + }); + }; + + const handlePreviousMonth = () => { + setCurrentDate(subMonths(currentDate, 1)); + }; + + const handleNextMonth = () => { + setCurrentDate(addMonths(currentDate, 1)); + }; + + const handleToday = () => { + setCurrentDate(new Date()); + }; + + const handleSequenceClick = (sequence: any) => { + setSelectedSequence(sequence); + setIsDialogOpen(true); + }; + + const getPublicCibleLabel = (publicCible: string) => { + switch (publicCible) { + case "directeur": return "Directeur"; + case "chef_service": return "Chef de service"; + case "tous": return "Tous"; + case "autre": return "Autre"; + default: return publicCible; + } + }; + + const getStatutBadge = (statut: string) => { + switch (statut) { + case "ouverte": return "bg-green-100 text-green-800"; + case "bloquee": return "bg-orange-100 text-orange-800"; + case "terminee": return "bg-gray-100 text-gray-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + return ( + +
+
+

Calendrier des séquences

+

+ Vue mensuelle de toutes les séquences de formation planifiées +

+
+ + + +
+
+ + {format(currentDate, "MMMM yyyy", { locale: fr })} + + + {sequences.length} séquence(s) au total + +
+
+ + + +
+
+
+ + {isLoading ? ( +
Chargement...
+ ) : ( +
+ {/* En-tête des jours de la semaine */} +
+ {["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map(day => ( +
+ {day} +
+ ))} +
+ + {/* Grille du calendrier */} +
+ {calendarDays.map((day, index) => { + const daySequences = getSequencesForDay(day); + const isCurrentMonth = isSameMonth(day, currentDate); + const isToday = isSameDay(day, new Date()); + + return ( +
+
+ {format(day, "d")} +
+
+ {daySequences.map(sequence => ( + + ))} +
+
+ ); + })} +
+
+ )} +
+
+ + {/* Dialog de détails de la séquence */} + + + + {selectedSequence?.nom} + + {selectedSequence && getFormationName(selectedSequence.formationId)} + + + {selectedSequence && ( +
+
+
+

Lieu

+

{selectedSequence.lieu}

+
+
+

Public cible

+

{getPublicCibleLabel(selectedSequence.publicCible)}

+
+
+

Formateur

+

{selectedSequence.formateur || "-"}

+
+
+

Capacité

+

{selectedSequence.nbInscrits || 0} / {selectedSequence.capaciteMax}

+
+
+

Statut

+ + {selectedSequence.statut} + +
+
+ +
+

Dates de formation

+
+ {selectedSequence.dates.map((date: any) => ( +
+

Date {date.ordre}

+

+ Du {format(new Date(date.dateDebut), "PPP 'à' HH:mm", { locale: fr })} +

+

+ au {format(new Date(date.dateFin), "PPP 'à' HH:mm", { locale: fr })} +

+
+ ))} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/client/src/pages/AdminRappels.tsx b/client/src/pages/AdminRappels.tsx new file mode 100644 index 0000000..18c1538 --- /dev/null +++ b/client/src/pages/AdminRappels.tsx @@ -0,0 +1,318 @@ +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, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { trpc } from "@/lib/trpc"; +import { Plus, Pencil, Trash2, Bell, BellOff } from "lucide-react"; +import { toast } from "sonner"; + +export default function AdminRappels() { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [formData, setFormData] = useState({ + nom: "", + joursAvant: 15, + emailTemplate: "", + actif: true, + }); + const [editingId, setEditingId] = useState(null); + + const { data: rappels = [], isLoading, refetch } = trpc.rappels.list.useQuery(); + const createMutation = trpc.rappels.create.useMutation(); + const updateMutation = trpc.rappels.update.useMutation(); + const deleteMutation = trpc.rappels.delete.useMutation(); + + const resetForm = () => { + setFormData({ + nom: "", + joursAvant: 15, + emailTemplate: "", + actif: true, + }); + setEditingId(null); + }; + + const handleCreate = async () => { + try { + await createMutation.mutateAsync(formData); + toast.success("Rappel créé avec succès"); + setIsCreateDialogOpen(false); + resetForm(); + refetch(); + } catch (error: any) { + toast.error(error.message || "Erreur lors de la création du rappel"); + } + }; + + const handleEdit = (rappel: any) => { + setFormData({ + nom: rappel.nom, + joursAvant: rappel.joursAvant, + emailTemplate: rappel.emailTemplate, + actif: rappel.actif, + }); + setEditingId(rappel.id); + setIsEditDialogOpen(true); + }; + + const handleUpdate = async () => { + if (!editingId) return; + + try { + await updateMutation.mutateAsync({ id: editingId, ...formData }); + toast.success("Rappel mis à jour avec succès"); + setIsEditDialogOpen(false); + resetForm(); + refetch(); + } catch (error: any) { + toast.error(error.message || "Erreur lors de la mise à jour du rappel"); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("Êtes-vous sûr de vouloir supprimer ce rappel ?")) return; + + try { + await deleteMutation.mutateAsync({ id }); + toast.success("Rappel supprimé avec succès"); + refetch(); + } catch (error: any) { + toast.error(error.message || "Erreur lors de la suppression du rappel"); + } + }; + + const templateExemple = `Bonjour {{prenom}} {{nom}}, + +Nous vous rappelons que votre formation "{{formationNom}}" - {{sequenceNom}} aura lieu dans {{joursAvant}} jours. + +📅 Date : {{dateDebut}} +📍 Lieu : {{lieu}} +👨‍🏫 Formateur : {{formateur}} + +Cordialement, +L'équipe Formation Manager Itinova`; + + return ( + +
+
+
+

Rappels automatiques

+

+ Configurez les rappels envoyés automatiquement avant les formations +

+
+ + + + + + + Créer un rappel + + Configurez un nouveau rappel automatique à envoyer avant les formations + + +
+
+ + setFormData({ ...formData, nom: e.target.value })} + /> +
+
+ + setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 })} + /> +

+ Nombre de jours avant la première date de formation +

+
+
+ +