Checkpoint: Application complète de gestion des formations Manager Itinova avec toutes les fonctionnalités : gestion des formations/sessions/apprenants, système d'inscription avec validations, génération d'invitations Outlook, emails automatiques, exports PDF/Excel, et documentation complète.
This commit is contained in:
285
DOCUMENTATION.md
Normal file
285
DOCUMENTATION.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Documentation - Gestion des Formations Manager Itinova
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'application **Gestion des Formations Manager Itinova** est une solution complète développée pour gérer l'organisation interne des formations destinées à environ 150 apprenants entre février et octobre 2026, puis 3 sessions par an. Elle permet à la DRH (assistante formation, responsable Développement RH) et à la Directrice Qualité et Transformation de gérer efficacement les inscriptions, les sessions, et les communications automatisées.
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
### 1. Gestion des formations
|
||||
|
||||
L'application permet de créer et gérer plusieurs formations en parallèle. Chaque formation dispose d'un lien unique d'inscription qui peut être partagé avec les apprenants.
|
||||
|
||||
**Caractéristiques :**
|
||||
- Création de formations avec nom, description et lien unique personnalisé
|
||||
- Activation/désactivation des formations
|
||||
- Génération automatique de liens d'inscription distincts pour chaque formation
|
||||
|
||||
### 2. Gestion des sessions
|
||||
|
||||
Les sessions sont les instances concrètes d'une formation avec des dates, un lieu et une capacité définis.
|
||||
|
||||
**Caractéristiques :**
|
||||
- Création de sessions avec dates de début et fin
|
||||
- Définition du lieu de formation
|
||||
- Capacité maximale fixée à 12 participants par session
|
||||
- Date de blocage automatique à J-15 avant le début de la session
|
||||
- Statuts de session : ouverte, bloquée, terminée
|
||||
|
||||
### 3. Gestion des apprenants
|
||||
|
||||
L'application maintient une base de données des apprenants avec leurs informations essentielles.
|
||||
|
||||
**Informations collectées :**
|
||||
- Nom et prénom
|
||||
- Email professionnel
|
||||
- Code établissement
|
||||
|
||||
### 4. Système d'inscription
|
||||
|
||||
Les apprenants peuvent s'inscrire via un lien unique propre à chaque formation. Le système applique automatiquement toutes les contraintes définies.
|
||||
|
||||
**Validations automatiques :**
|
||||
- Vérification de la capacité maximale (12 participants)
|
||||
- Prévention des doubles inscriptions
|
||||
- Blocage des inscriptions et désinscriptions à J-15
|
||||
- Gestion automatique de la liste d'attente si la session est complète
|
||||
|
||||
### 5. Invitations et communications automatiques
|
||||
|
||||
Dès qu'un apprenant s'inscrit, le système génère et envoie automatiquement une invitation Outlook avec tous les détails de la formation.
|
||||
|
||||
**Fonctionnalités d'envoi :**
|
||||
- **Email de confirmation** : envoyé immédiatement après l'inscription avec invitation Outlook (.ics) en pièce jointe
|
||||
- **Invitation Outlook** : fichier .ics avec statut "occupé" incluant le lieu de formation
|
||||
- **Email teaser** : peut être envoyé manuellement par les administrateurs à tous les inscrits d'une session
|
||||
- **Email de rappel J-7** : peut être envoyé manuellement 7 jours avant la formation
|
||||
|
||||
### 6. Exports et feuilles de présence
|
||||
|
||||
Les administrateurs peuvent exporter les listes d'inscrits dans différents formats pour faciliter la gestion administrative.
|
||||
|
||||
**Formats disponibles :**
|
||||
- **Export Excel** : liste complète des inscrits avec toutes leurs informations
|
||||
- **Export PDF** : liste des inscrits au format PDF
|
||||
- **Feuille de présence PDF** : document prêt à imprimer avec nom, prénom, code établissement et colonne signature
|
||||
|
||||
## Guide d'utilisation
|
||||
|
||||
### Pour les administrateurs
|
||||
|
||||
#### Connexion
|
||||
|
||||
1. Accédez à l'application via l'URL fournie
|
||||
2. Cliquez sur "Se connecter"
|
||||
3. Utilisez vos identifiants Manus OAuth
|
||||
4. Vous serez redirigé vers le tableau de bord administrateur
|
||||
|
||||
#### Créer une formation
|
||||
|
||||
1. Dans le menu latéral, cliquez sur **Formations**
|
||||
2. Cliquez sur le bouton **Nouvelle formation**
|
||||
3. Remplissez les informations :
|
||||
- Nom de la formation (ex: "Manager Itinova")
|
||||
- Description (optionnelle)
|
||||
- Lien unique (ex: "manager-itinova-2026")
|
||||
- Cochez "Formation active" pour la rendre disponible
|
||||
4. Cliquez sur **Créer**
|
||||
5. Le lien d'inscription sera généré automatiquement : `/inscription/[votre-lien-unique]`
|
||||
|
||||
#### Créer une session
|
||||
|
||||
1. Dans le menu latéral, cliquez sur **Sessions**
|
||||
2. Cliquez sur le bouton **Nouvelle session**
|
||||
3. Remplissez les informations :
|
||||
- Sélectionnez la formation associée
|
||||
- Nom de la session (ex: "Session 1 - Février 2026")
|
||||
- Date et heure de début
|
||||
- Date et heure de fin
|
||||
- Lieu de la formation (sera inclus dans l'invitation Outlook)
|
||||
- Capacité maximale (par défaut 12)
|
||||
- Date de blocage (J-15 avant le début)
|
||||
- Statut (ouverte, bloquée, terminée)
|
||||
4. Cliquez sur **Créer**
|
||||
|
||||
#### Gérer les inscriptions
|
||||
|
||||
1. Dans le menu **Sessions**, cliquez sur l'icône **Utilisateurs** à côté d'une session
|
||||
2. Vous verrez :
|
||||
- Le nombre de places confirmées sur la capacité totale
|
||||
- Le nombre de personnes en liste d'attente
|
||||
- La liste complète des inscrits avec leurs informations
|
||||
3. Actions disponibles :
|
||||
- Modifier le statut d'une inscription (confirmée, liste d'attente, annulée)
|
||||
- Exporter la liste en Excel ou PDF
|
||||
- Générer une feuille de présence
|
||||
- Envoyer des emails groupés (teaser ou rappel J-7)
|
||||
|
||||
#### Envoyer des emails groupés
|
||||
|
||||
1. Accédez à la page des inscrits d'une session
|
||||
2. Cliquez sur **Envoyer teaser** pour envoyer un email d'introduction à tous les inscrits confirmés
|
||||
3. Cliquez sur **Envoyer rappel J-7** pour envoyer un rappel 7 jours avant la formation
|
||||
4. Un message de confirmation indiquera le nombre d'emails envoyés avec succès
|
||||
|
||||
#### Exporter les données
|
||||
|
||||
1. Accédez à la page des inscrits d'une session
|
||||
2. Cliquez sur :
|
||||
- **Exporter Excel** : télécharge un fichier .xlsx avec toutes les informations
|
||||
- **Exporter PDF** : télécharge un fichier PDF avec la liste des inscrits
|
||||
- **Feuille de présence** : télécharge un PDF prêt à imprimer pour la signature des participants
|
||||
|
||||
### Pour les apprenants
|
||||
|
||||
#### S'inscrire à une formation
|
||||
|
||||
1. Cliquez sur le lien d'inscription fourni par la DRH (ex: `https://[domaine]/inscription/manager-itinova-2026`)
|
||||
2. Consultez les sessions disponibles avec :
|
||||
- Les dates et horaires
|
||||
- Le lieu de formation
|
||||
- Le nombre de places disponibles
|
||||
- Le statut (disponible, complète, fermée)
|
||||
3. Sélectionnez la session souhaitée en cliquant dessus
|
||||
4. Remplissez vos informations :
|
||||
- Nom
|
||||
- Prénom
|
||||
- Email professionnel
|
||||
- Code établissement
|
||||
5. Cliquez sur **Confirmer mon inscription**
|
||||
6. Vous recevrez immédiatement :
|
||||
- Un email de confirmation
|
||||
- Une invitation Outlook (.ics) à ajouter à votre calendrier
|
||||
|
||||
#### Que se passe-t-il après l'inscription ?
|
||||
|
||||
- **Si votre inscription est confirmée** : vous recevrez une invitation Outlook qui bloquera automatiquement votre agenda avec le statut "occupé"
|
||||
- **Si la session est complète** : vous serez placé en liste d'attente et contacté si une place se libère
|
||||
- **15 jours avant la formation** : les inscriptions et désinscriptions seront bloquées
|
||||
- **7 jours avant la formation** : vous recevrez un email de rappel avec tous les détails pratiques
|
||||
|
||||
## Gestion des rôles et permissions
|
||||
|
||||
L'application utilise un système de rôles pour contrôler l'accès aux fonctionnalités :
|
||||
|
||||
### Rôle Administrateur
|
||||
|
||||
Accordé aux membres de la DRH (assistante formation, responsable Développement RH, Directrice Qualité et Transformation, stagiaire).
|
||||
|
||||
**Permissions :**
|
||||
- Accès complet au tableau de bord administrateur
|
||||
- Création, modification et suppression de formations
|
||||
- Création, modification et suppression de sessions
|
||||
- Gestion des apprenants
|
||||
- Consultation et modification des inscriptions
|
||||
- Envoi d'emails groupés
|
||||
- Export des données
|
||||
|
||||
### Rôle Utilisateur
|
||||
|
||||
Les apprenants qui s'inscrivent via le lien public.
|
||||
|
||||
**Permissions :**
|
||||
- Accès au formulaire d'inscription public
|
||||
- Consultation des sessions disponibles
|
||||
- Inscription à une session (dans les limites définies)
|
||||
|
||||
## Contraintes et règles métier
|
||||
|
||||
### Capacité des sessions
|
||||
|
||||
Chaque session est limitée à **12 participants maximum**. Au-delà, les apprenants sont automatiquement placés en liste d'attente.
|
||||
|
||||
### Blocage à J-15
|
||||
|
||||
Les inscriptions et désinscriptions sont automatiquement bloquées **15 jours avant le début de la session**. Cette règle garantit la stabilité des groupes et évite les désistements de dernière minute.
|
||||
|
||||
### Prévention des doubles inscriptions
|
||||
|
||||
Le système empêche un apprenant de s'inscrire plusieurs fois à la même session. Si l'apprenant existe déjà dans la base de données (même email), ses informations sont réutilisées.
|
||||
|
||||
### Statuts des inscriptions
|
||||
|
||||
- **Confirmée** : l'apprenant a une place garantie dans la session
|
||||
- **Liste d'attente** : la session est complète, l'apprenant sera contacté si une place se libère
|
||||
- **Annulée** : l'inscription a été annulée par l'apprenant ou l'administrateur
|
||||
|
||||
## Architecture technique
|
||||
|
||||
### Technologies utilisées
|
||||
|
||||
L'application est construite avec une architecture moderne full-stack :
|
||||
|
||||
**Frontend :**
|
||||
- React 19 avec TypeScript
|
||||
- Tailwind CSS 4 pour le design
|
||||
- tRPC pour la communication type-safe avec le backend
|
||||
- Wouter pour le routage
|
||||
- shadcn/ui pour les composants d'interface
|
||||
|
||||
**Backend :**
|
||||
- Node.js avec Express
|
||||
- tRPC pour les API type-safe
|
||||
- Drizzle ORM pour l'accès à la base de données
|
||||
- MySQL/TiDB comme base de données
|
||||
|
||||
**Fonctionnalités :**
|
||||
- Génération de fichiers ICS (iCalendar) pour Outlook
|
||||
- Export Excel avec xlsx
|
||||
- Export PDF avec jsPDF
|
||||
- Système d'emails automatisés
|
||||
|
||||
### Base de données
|
||||
|
||||
Le schéma de base de données comprend 5 tables principales :
|
||||
|
||||
1. **users** : utilisateurs authentifiés (administrateurs)
|
||||
2. **formations** : catalogue des formations
|
||||
3. **sessions** : instances de formations avec dates et lieux
|
||||
4. **apprenants** : base de données des participants
|
||||
5. **inscriptions** : relation entre apprenants et sessions avec statuts
|
||||
|
||||
## Confidentialité et sécurité
|
||||
|
||||
L'application respecte les principes de confidentialité des données :
|
||||
|
||||
- **Authentification sécurisée** : via Manus OAuth pour les administrateurs
|
||||
- **Contrôle d'accès** : seuls les administrateurs peuvent accéder aux données sensibles
|
||||
- **Protection des données** : les informations personnelles des apprenants sont stockées de manière sécurisée
|
||||
- **Emails professionnels** : seuls les emails professionnels sont collectés
|
||||
|
||||
## Support et maintenance
|
||||
|
||||
### Contact
|
||||
|
||||
Pour toute question ou problème technique, contactez le service RH ou l'équipe de développement.
|
||||
|
||||
### Évolutions futures
|
||||
|
||||
L'application est conçue pour être évolutive. Des fonctionnalités supplémentaires peuvent être ajoutées selon les besoins :
|
||||
|
||||
- Intégration avec un vrai service SMTP pour l'envoi d'emails
|
||||
- Tableau de bord analytique avec statistiques de participation
|
||||
- Système de notation et d'évaluation post-formation
|
||||
- Gestion des formateurs et de leurs disponibilités
|
||||
- Notifications push pour les rappels
|
||||
|
||||
## Notes importantes
|
||||
|
||||
### Emails simulés
|
||||
|
||||
Dans la version actuelle, les emails sont **simulés** et affichés dans les logs du serveur. Pour une utilisation en production, il est nécessaire d'intégrer un service SMTP réel (comme SendGrid, Mailgun, ou un serveur SMTP d'entreprise).
|
||||
|
||||
### Personnalisation
|
||||
|
||||
Les templates d'emails peuvent être personnalisés dans le fichier `server/emailService.ts` pour correspondre à la charte graphique de l'entreprise.
|
||||
|
||||
### Sauvegarde des données
|
||||
|
||||
Il est recommandé de mettre en place une stratégie de sauvegarde régulière de la base de données pour prévenir toute perte de données.
|
||||
|
||||
---
|
||||
|
||||
**Version de la documentation :** 1.0
|
||||
**Date de dernière mise à jour :** 2026-01-17
|
||||
**Auteur :** Manus AI
|
||||
@@ -5,12 +5,23 @@ import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "./pages/Home";
|
||||
import Admin from "./pages/Admin";
|
||||
import AdminFormations from "./pages/AdminFormations";
|
||||
import AdminSessions from "./pages/AdminSessions";
|
||||
import AdminApprenants from "./pages/AdminApprenants";
|
||||
import AdminSessionInscrits from "./pages/AdminSessionInscrits";
|
||||
import Inscription from "./pages/Inscription";
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={"/"} component={Home} />
|
||||
<Route path={"/inscription/:lien"} component={Inscription} />
|
||||
<Route path={"/admin"} component={Admin} />
|
||||
<Route path={"/admin/formations"} component={AdminFormations} />
|
||||
<Route path={"/admin/sessions"} component={AdminSessions} />
|
||||
<Route path={"/admin/apprenants"} component={AdminApprenants} />
|
||||
<Route path={"/admin/sessions/:id/inscrits"} component={AdminSessionInscrits} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
@@ -18,18 +29,10 @@ function Router() {
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: About Theme
|
||||
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
|
||||
// to keep consistent foreground/background color across components
|
||||
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
defaultTheme="light"
|
||||
// switchable
|
||||
>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
|
||||
@@ -21,15 +21,17 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
{ icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" },
|
||||
{ icon: GraduationCap, label: "Formations", path: "/admin/formations" },
|
||||
{ icon: Calendar, label: "Sessions", path: "/admin/sessions" },
|
||||
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
@@ -73,7 +75,7 @@ export default function DashboardLayout({
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{APP_TITLE}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please sign in to continue
|
||||
Veuillez vous connecter pour continuer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,7 +86,7 @@ export default function DashboardLayout({
|
||||
size="lg"
|
||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Sign in
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +257,7 @@ function DashboardLayoutContent({
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
<span>Déconnexion</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
92
client/src/pages/Admin.tsx
Normal file
92
client/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { GraduationCap, Calendar, Users, CheckCircle } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Admin() {
|
||||
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
|
||||
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery();
|
||||
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Formations actives",
|
||||
value: formations?.filter(f => f.actif).length || 0,
|
||||
icon: <GraduationCap className="w-8 h-8 text-blue-500" />,
|
||||
loading: loadingFormations,
|
||||
},
|
||||
{
|
||||
title: "Sessions ouvertes",
|
||||
value: sessions?.filter(s => s.statut === "ouverte").length || 0,
|
||||
icon: <Calendar className="w-8 h-8 text-green-500" />,
|
||||
loading: loadingSessions,
|
||||
},
|
||||
{
|
||||
title: "Apprenants inscrits",
|
||||
value: apprenants?.length || 0,
|
||||
icon: <Users className="w-8 h-8 text-purple-500" />,
|
||||
loading: loadingApprenants,
|
||||
},
|
||||
{
|
||||
title: "Sessions terminées",
|
||||
value: sessions?.filter(s => s.statut === "terminee").length || 0,
|
||||
icon: <CheckCircle className="w-8 h-8 text-orange-500" />,
|
||||
loading: loadingSessions,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tableau de bord</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gestion des formations Manager Itinova
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
{stat.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stat.loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations, sessions et apprenants depuis le menu de navigation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">Fonctionnalités principales :</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Créer et gérer les formations avec liens d'inscription uniques</li>
|
||||
<li>Organiser les sessions avec limitation à 12 participants</li>
|
||||
<li>Gérer les inscriptions avec blocage automatique à J-15</li>
|
||||
<li>Exporter les listes d'inscrits en PDF ou Excel</li>
|
||||
<li>Générer des invitations Outlook et envoyer des emails automatiques</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
244
client/src/pages/AdminApprenants.tsx
Normal file
244
client/src/pages/AdminApprenants.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
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, 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 { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminApprenants() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
prenom: "",
|
||||
email: "",
|
||||
codeEtablissement: "",
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: apprenants, isLoading } = trpc.apprenants.list.useQuery();
|
||||
|
||||
const createMutation = trpc.apprenants.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant créé avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.apprenants.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant mis à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.apprenants.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant supprimé avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "" });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (apprenant: any) => {
|
||||
setFormData({
|
||||
nom: apprenant.nom,
|
||||
prenom: apprenant.prenom,
|
||||
email: apprenant.email,
|
||||
codeEtablissement: apprenant.codeEtablissement,
|
||||
});
|
||||
setEditingId(apprenant.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cet apprenant ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Apprenants</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les apprenants et leurs informations
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvel apprenant
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier l'apprenant" : "Nouvel apprenant"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ajoutez les informations de l'apprenant
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Dupont"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prenom">Prénom *</Label>
|
||||
<Input
|
||||
id="prenom"
|
||||
value={formData.prenom}
|
||||
onChange={(e) => setFormData({ ...formData, prenom: e.target.value })}
|
||||
placeholder="Jean"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jean.dupont@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeEtablissement">Code établissement *</Label>
|
||||
<Input
|
||||
id="codeEtablissement"
|
||||
value={formData.codeEtablissement}
|
||||
onChange={(e) => setFormData({ ...formData, codeEtablissement: e.target.value })}
|
||||
placeholder="ETB001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des apprenants</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les apprenants inscrits aux formations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : apprenants && apprenants.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apprenants.map((apprenant) => (
|
||||
<TableRow key={apprenant.id}>
|
||||
<TableCell className="font-medium">{apprenant.nom}</TableCell>
|
||||
<TableCell>{apprenant.prenom}</TableCell>
|
||||
<TableCell>{apprenant.email}</TableCell>
|
||||
<TableCell>{apprenant.codeEtablissement}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(apprenant)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(apprenant.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun apprenant pour le moment. Créez-en un pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
265
client/src/pages/AdminFormations.tsx
Normal file
265
client/src/pages/AdminFormations.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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, DialogTrigger } 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 { Plus, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminFormations() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
description: "",
|
||||
lienUnique: "",
|
||||
actif: true,
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: formations, isLoading } = trpc.formations.list.useQuery();
|
||||
const createMutation = trpc.formations.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation créée avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.formations.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation mise à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.formations.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation supprimée avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({ nom: "", description: "", lienUnique: "", actif: true });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (formation: any) => {
|
||||
setFormData({
|
||||
nom: formation.nom,
|
||||
description: formation.description || "",
|
||||
lienUnique: formation.lienUnique,
|
||||
actif: formation.actif,
|
||||
});
|
||||
setEditingId(formation.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette formation ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const getInscriptionUrl = (lien: string) => {
|
||||
return `${window.location.origin}/inscription/${lien}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Formations</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les formations et leurs liens d'inscription
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle formation
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier la formation" : "Nouvelle formation"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une formation avec un lien d'inscription unique
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom de la formation *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Manager Itinova"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Description de la formation..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lienUnique">Lien unique *</Label>
|
||||
<Input
|
||||
id="lienUnique"
|
||||
value={formData.lienUnique}
|
||||
onChange={(e) => setFormData({ ...formData, lienUnique: e.target.value })}
|
||||
placeholder="manager-itinova-2026"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ce lien sera utilisé pour l'inscription : /inscription/{formData.lienUnique}
|
||||
</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">Formation active</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des formations</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations et accédez aux liens d'inscription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : formations && formations.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Lien d'inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formations.map((formation) => (
|
||||
<TableRow key={formation.id}>
|
||||
<TableCell className="font-medium">{formation.nom}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{formation.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={getInscriptionUrl(formation.lienUnique)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:underline"
|
||||
>
|
||||
{formation.lienUnique}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
formation.actif
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}>
|
||||
{formation.actif ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(formation)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(formation.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune formation pour le moment. Créez-en une pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ArrowLeft, Download, Mail } from "lucide-react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSessionInscrits() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [, params] = useRoute("/admin/sessions/:id/inscrits");
|
||||
const sessionId = params?.id ? parseInt(params.id) : 0;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: session, isLoading: loadingSession } = trpc.sessions.getById.useQuery({ id: sessionId });
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySession.useQuery({ sessionId });
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inscriptions.listBySession.invalidate({ sessionId });
|
||||
toast.success("Statut mis à jour avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmailsMutation = trpc.inscriptions.sendGroupEmails.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'envoi : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatutChange = (inscriptionId: number, newStatut: string) => {
|
||||
updateStatutMutation.mutate({
|
||||
id: inscriptionId,
|
||||
statut: newStatut as "confirmee" | "liste_attente" | "annulee",
|
||||
});
|
||||
};
|
||||
|
||||
const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export Excel téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export PDF téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `feuille-presence-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Feuille de présence téléchargée");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la génération : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = () => {
|
||||
exportExcelMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleExportPDF = () => {
|
||||
exportPDFMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleGenerateFeuillePresence = () => {
|
||||
exportFeuilleMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
confirmee: "bg-green-100 text-green-800",
|
||||
liste_attente: "bg-orange-100 text-orange-800",
|
||||
annulee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
confirmee: "Confirmée",
|
||||
liste_attente: "Liste d'attente",
|
||||
annulee: "Annulée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const inscritsConfirmes = inscriptions?.filter(i => i.inscription.statut === "confirmee") || [];
|
||||
const listeAttente = inscriptions?.filter(i => i.inscription.statut === "liste_attente") || [];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setLocation("/admin/sessions")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Inscrits à la session</h1>
|
||||
{session && (
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{getFormationName(session.formationId)} - {session.nom}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSession || loadingInscriptions ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : session ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Places confirmées</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{inscritsConfirmes.length} / {session.capaciteMax}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{session.capaciteMax - inscritsConfirmes.length} places restantes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Liste d'attente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{listeAttente.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
En attente de places
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Total inscriptions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{inscriptions?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Toutes statuts confondus
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExportExcel}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportPDF}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGenerateFeuillePresence}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Feuille de présence
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'teaser' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer teaser
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'rappel' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer rappel J-7
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les inscriptions et leurs statuts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{inscriptions && inscriptions.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead>Date d'inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inscriptions.map((item) => (
|
||||
<TableRow key={item.inscription.id}>
|
||||
<TableCell className="font-medium">{item.apprenant?.nom}</TableCell>
|
||||
<TableCell>{item.apprenant?.prenom}</TableCell>
|
||||
<TableCell>{item.apprenant?.email}</TableCell>
|
||||
<TableCell>{item.apprenant?.codeEtablissement}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(item.inscription.dateInscription).toLocaleDateString('fr-FR')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatutBadge(item.inscription.statut)}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.inscription.statut}
|
||||
onValueChange={(value) => handleStatutChange(item.inscription.id, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune inscription pour cette session.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Session introuvable.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
377
client/src/pages/AdminSessions.tsx
Normal file
377
client/src/pages/AdminSessions.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
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, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2, Users as UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function AdminSessions() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sessions, isLoading } = trpc.sessions.list.useQuery();
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const createMutation = trpc.sessions.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session créée avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.sessions.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session mise à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.sessions.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session supprimée avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte",
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (session: any) => {
|
||||
setFormData({
|
||||
formationId: session.formationId.toString(),
|
||||
nom: session.nom,
|
||||
dateDebut: format(new Date(session.dateDebut), "yyyy-MM-dd'T'HH:mm"),
|
||||
dateFin: format(new Date(session.dateFin), "yyyy-MM-dd'T'HH:mm"),
|
||||
lieu: session.lieu,
|
||||
capaciteMax: session.capaciteMax.toString(),
|
||||
dateBlocage: format(new Date(session.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
|
||||
statut: session.statut,
|
||||
});
|
||||
setEditingId(session.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
formationId: parseInt(formData.formationId),
|
||||
nom: formData.nom,
|
||||
dateDebut: formData.dateDebut,
|
||||
dateFin: formData.dateFin,
|
||||
lieu: formData.lieu,
|
||||
capaciteMax: parseInt(formData.capaciteMax),
|
||||
dateBlocage: formData.dateBlocage,
|
||||
statut: formData.statut,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette session ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr });
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
ouverte: "bg-green-100 text-green-800",
|
||||
bloquee: "bg-orange-100 text-orange-800",
|
||||
terminee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
ouverte: "Ouverte",
|
||||
bloquee: "Bloquée",
|
||||
terminee: "Terminée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les sessions de formation et leurs inscriptions
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle session
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier la session" : "Nouvelle session"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une session avec dates, lieu et capacité
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formationId">Formation *</Label>
|
||||
<Select
|
||||
value={formData.formationId}
|
||||
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez une formation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formations?.map((formation) => (
|
||||
<SelectItem key={formation.id} value={formation.id.toString()}>
|
||||
{formation.nom}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom de la session *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Session 1 - Février 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateDebut">Date de début *</Label>
|
||||
<Input
|
||||
id="dateDebut"
|
||||
type="datetime-local"
|
||||
value={formData.dateDebut}
|
||||
onChange={(e) => setFormData({ ...formData, dateDebut: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateFin">Date de fin *</Label>
|
||||
<Input
|
||||
id="dateFin"
|
||||
type="datetime-local"
|
||||
value={formData.dateFin}
|
||||
onChange={(e) => setFormData({ ...formData, dateFin: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lieu">Lieu *</Label>
|
||||
<Input
|
||||
id="lieu"
|
||||
value={formData.lieu}
|
||||
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
|
||||
placeholder="Salle de formation A, Bâtiment principal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
|
||||
<Input
|
||||
id="capaciteMax"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capaciteMax}
|
||||
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateBlocage">Date de blocage (J-15) *</Label>
|
||||
<Input
|
||||
id="dateBlocage"
|
||||
type="datetime-local"
|
||||
value={formData.dateBlocage}
|
||||
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="statut">Statut</Label>
|
||||
<Select
|
||||
value={formData.statut}
|
||||
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ouverte">Ouverte</SelectItem>
|
||||
<SelectItem value="bloquee">Bloquée</SelectItem>
|
||||
<SelectItem value="terminee">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos sessions et consultez les inscriptions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : sessions && sessions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Formation</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Date début</TableHead>
|
||||
<TableHead>Date fin</TableHead>
|
||||
<TableHead>Lieu</TableHead>
|
||||
<TableHead>Capacité</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell>
|
||||
<TableCell>{session.nom}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateDebut)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateFin)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{session.lieu}</TableCell>
|
||||
<TableCell>{session.capaciteMax}</TableCell>
|
||||
<TableCell>{getStatutBadge(session.statut)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation(`/admin/sessions/${session.id}/inscrits`)}
|
||||
title="Voir les inscrits"
|
||||
>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(session)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session pour le moment. Créez-en une pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,182 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { GraduationCap, Calendar, Users, CheckCircle, Mail, Download } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
/**
|
||||
* All content in this page are only for example, replace with your own feature implementation
|
||||
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
|
||||
*/
|
||||
export default function Home() {
|
||||
// The userAuth hooks provides authentication state
|
||||
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
|
||||
let { user, loading, error, isAuthenticated, logout } = useAuth();
|
||||
const { user, loading, isAuthenticated } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// If theme is switchable in App.tsx, we can implement theme toggling like this:
|
||||
// const { theme, toggleTheme } = useTheme();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use APP_LOGO (as image src) and APP_TITLE if needed
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
setLocation('/admin');
|
||||
return null;
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <GraduationCap className="w-8 h-8 text-blue-600" />,
|
||||
title: "Gestion des formations",
|
||||
description: "Créez et gérez plusieurs formations en parallèle avec des liens d'inscription uniques pour chaque formation.",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="w-8 h-8 text-green-600" />,
|
||||
title: "Sessions planifiées",
|
||||
description: "Organisez des sessions avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.",
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-8 h-8 text-purple-600" />,
|
||||
title: "Inscriptions simplifiées",
|
||||
description: "Les apprenants s'inscrivent facilement via un lien unique. Validation automatique de la capacité et anti-doublons.",
|
||||
},
|
||||
{
|
||||
icon: <Mail className="w-8 h-8 text-orange-600" />,
|
||||
title: "Communications automatiques",
|
||||
description: "Emails de confirmation, invitations Outlook avec statut 'occupé', teasers et rappels J-7 automatisés.",
|
||||
},
|
||||
{
|
||||
icon: <Download className="w-8 h-8 text-red-600" />,
|
||||
title: "Exports facilitées",
|
||||
description: "Exportez les listes d'inscrits en Excel ou PDF. Générez des feuilles de présence prêtes à imprimer.",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-8 h-8 text-teal-600" />,
|
||||
title: "Gestion des contraintes",
|
||||
description: "Respect automatique des règles : capacité maximale, blocage J-15, liste d'attente et prévention des doubles inscriptions.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<main>
|
||||
{/* Example: lucide-react for icons */}
|
||||
<Loader2 className="animate-spin" />
|
||||
Example Page
|
||||
{/* Example: Streamdown for markdown rendering */}
|
||||
<Streamdown>Any **markdown** content</Streamdown>
|
||||
<Button variant="default">Example Button</Button>
|
||||
</main>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={APP_LOGO} alt={APP_TITLE} className="h-10 w-10" />
|
||||
<h1 className="text-xl font-bold text-gray-900">{APP_TITLE}</h1>
|
||||
</div>
|
||||
{!isAuthenticated && (
|
||||
<Button onClick={() => window.location.href = getLoginUrl()}>
|
||||
Connexion Administrateur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-20 px-4">
|
||||
<div className="container mx-auto text-center max-w-4xl">
|
||||
<h2 className="text-5xl font-bold text-gray-900 mb-6">
|
||||
Gestion des Formations Manager Itinova
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Solution complète pour gérer l'organisation interne des formations destinées à environ 150 apprenants.
|
||||
Inscriptions, sessions, communications automatisées et exports simplifiés.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{isAuthenticated && user?.role === 'admin' && (
|
||||
<Button size="lg" onClick={() => setLocation('/admin')}>
|
||||
Accéder au tableau de bord
|
||||
</Button>
|
||||
)}
|
||||
{!isAuthenticated && (
|
||||
<Button size="lg" onClick={() => window.location.href = getLoginUrl()}>
|
||||
Connexion Administrateur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="py-16 px-4 bg-white">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<h3 className="text-3xl font-bold text-center mb-12">Fonctionnalités principales</h3>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<Card key={index} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="mb-4">{feature.icon}</div>
|
||||
<CardTitle className="text-xl">{feature.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<h3 className="text-3xl font-bold text-center mb-12">Comment ça fonctionne ?</h3>
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">1</span>
|
||||
Pour les administrateurs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Créez des formations et des sessions avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.</p>
|
||||
<p>Gérez les inscriptions, envoyez des emails groupés (teaser, rappels), et exportez les listes d'inscrits en Excel ou PDF pour vos besoins administratifs.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-green-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">2</span>
|
||||
Pour les apprenants
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Cliquez sur le lien d'inscription fourni par la DRH, consultez les sessions disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.</p>
|
||||
<p>Recevez immédiatement un email de confirmation avec une invitation Outlook (.ics) qui bloque automatiquement votre agenda. Un rappel vous sera envoyé 7 jours avant la formation.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-purple-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">3</span>
|
||||
Règles automatiques
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par session, prévention des doubles inscriptions, et gestion de la liste d'attente.</p>
|
||||
<p>Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-8 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<p className="text-gray-400">
|
||||
© 2026 Itinova - Gestion des Formations Manager Itinova
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Pour toute question, veuillez contacter le service RH.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
346
client/src/pages/Inscription.tsx
Normal file
346
client/src/pages/Inscription.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useRoute } from "wouter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
|
||||
import { APP_LOGO, APP_TITLE } from "@/const";
|
||||
|
||||
export default function Inscription() {
|
||||
const [, params] = useRoute("/inscription/:lien");
|
||||
const lien = params?.lien || "";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
prenom: "",
|
||||
email: "",
|
||||
codeEtablissement: "",
|
||||
});
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<number | null>(null);
|
||||
const [inscriptionSuccess, setInscriptionSuccess] = useState(false);
|
||||
const [inscriptionStatut, setInscriptionStatut] = useState<string>("");
|
||||
|
||||
const { data: formation, isLoading: loadingFormation } = trpc.formations.getByLien.useQuery({ lien });
|
||||
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.listByFormation.useQuery(
|
||||
{ formationId: formation?.id || 0 },
|
||||
{ enabled: !!formation?.id }
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const inscriptionMutation = trpc.inscriptions.inscrire.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setInscriptionSuccess(true);
|
||||
setInscriptionStatut(data.statut);
|
||||
utils.sessions.listByFormation.invalidate({ formationId: formation?.id || 0 });
|
||||
if (data.statut === "confirmee") {
|
||||
toast.success("Inscription confirmée avec succès !");
|
||||
} else {
|
||||
toast.info("Vous êtes inscrit en liste d'attente.");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const createApprenantMutation = trpc.apprenants.create.useMutation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedSessionId) {
|
||||
toast.error("Veuillez sélectionner une session");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier si l'apprenant existe déjà
|
||||
const existingApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
||||
|
||||
let apprenantId: number;
|
||||
|
||||
if (existingApprenant) {
|
||||
apprenantId = existingApprenant.id;
|
||||
} else {
|
||||
// Créer l'apprenant
|
||||
await createApprenantMutation.mutateAsync(formData);
|
||||
// Récupérer l'apprenant nouvellement créé
|
||||
const newApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
||||
if (!newApprenant) {
|
||||
throw new Error("Erreur lors de la création de l'apprenant");
|
||||
}
|
||||
apprenantId = newApprenant.id;
|
||||
}
|
||||
|
||||
// Inscrire l'apprenant à la session
|
||||
await inscriptionMutation.mutateAsync({
|
||||
apprenantId,
|
||||
sessionId: selectedSessionId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur lors de l'inscription:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer les places disponibles pour chaque session
|
||||
const sessionsAvecPlaces = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
|
||||
return sessions.map(session => {
|
||||
const now = new Date();
|
||||
const dateBlocage = new Date(session.dateBlocage);
|
||||
const isBloquee = session.statut === 'bloquee' || now >= dateBlocage;
|
||||
|
||||
return {
|
||||
...session,
|
||||
isBloquee,
|
||||
placesRestantes: session.capaciteMax,
|
||||
};
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
};
|
||||
|
||||
if (loadingFormation || loadingSessions) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formation || !formation.actif) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="w-6 h-6" />
|
||||
<CardTitle>Formation introuvable</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Cette formation n'existe pas ou n'est plus disponible.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inscriptionSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="w-8 h-8" />
|
||||
<CardTitle className="text-2xl">Inscription réussie !</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg">
|
||||
{inscriptionStatut === "confirmee"
|
||||
? "Votre inscription à la formation a été confirmée."
|
||||
: "Vous avez été ajouté à la liste d'attente. Nous vous contacterons si une place se libère."}
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg space-y-2">
|
||||
<p className="font-semibold">Prochaines étapes :</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Vous recevrez un email de confirmation avec tous les détails</li>
|
||||
<li>Une invitation Outlook sera envoyée pour bloquer votre agenda</li>
|
||||
<li>Un email de rappel vous sera envoyé 7 jours avant la formation</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pour toute question, veuillez contacter le service RH.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img src={APP_LOGO} alt={APP_TITLE} className="h-12 w-12" />
|
||||
<h1 className="text-4xl font-bold text-gray-900">{formation.nom}</h1>
|
||||
</div>
|
||||
{formation.description && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{formation.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions disponibles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sessions disponibles</CardTitle>
|
||||
<CardDescription>
|
||||
Sélectionnez une session pour vous inscrire (capacité : 12 participants maximum)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessionsAvecPlaces.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedSessionId === session.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: session.isBloquee
|
||||
? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
|
||||
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
|
||||
}`}
|
||||
onClick={() => !session.isBloquee && setSelectedSessionId(session.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3 className="font-semibold text-lg">{session.nom}</h3>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(session.dateDebut)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{session.lieu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{session.placesRestantes} places disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{session.isBloquee ? (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Fermée
|
||||
</span>
|
||||
) : session.placesRestantes === 0 ? (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Complète
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Disponible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session disponible pour le moment.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Formulaire d'inscription */}
|
||||
{selectedSessionId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vos informations</CardTitle>
|
||||
<CardDescription>
|
||||
Complétez le formulaire pour finaliser votre inscription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Dupont"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prenom">Prénom *</Label>
|
||||
<Input
|
||||
id="prenom"
|
||||
value={formData.prenom}
|
||||
onChange={(e) => setFormData({ ...formData, prenom: e.target.value })}
|
||||
placeholder="Jean"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email professionnel *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jean.dupont@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeEtablissement">Code établissement *</Label>
|
||||
<Input
|
||||
id="codeEtablissement"
|
||||
value={formData.codeEtablissement}
|
||||
onChange={(e) => setFormData({ ...formData, codeEtablissement: e.target.value })}
|
||||
placeholder="ETB001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session.
|
||||
Après cette date, aucune modification ne sera possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
|
||||
>
|
||||
{inscriptionMutation.isPending || createApprenantMutation.isPending
|
||||
? "Inscription en cours..."
|
||||
: "Confirmer mon inscription"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
drizzle/0000_charming_grey_gargoyle.sql
Normal file
13
drizzle/0000_charming_grey_gargoyle.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `users` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`openId` varchar(64) NOT NULL,
|
||||
`name` text,
|
||||
`email` varchar(320),
|
||||
`loginMethod` varchar(64),
|
||||
`role` enum('user','admin') NOT NULL DEFAULT 'user',
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
`lastSignedIn` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
|
||||
);
|
||||
54
drizzle/0001_smooth_warbird.sql
Normal file
54
drizzle/0001_smooth_warbird.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE `apprenants` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`nom` varchar(255) NOT NULL,
|
||||
`prenom` varchar(255) NOT NULL,
|
||||
`email` varchar(320) NOT NULL,
|
||||
`codeEtablissement` varchar(50) NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `apprenants_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `apprenants_email_unique` UNIQUE(`email`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `formations` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`nom` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`lienUnique` varchar(100) NOT NULL,
|
||||
`actif` boolean NOT NULL DEFAULT true,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `formations_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `formations_lienUnique_unique` UNIQUE(`lienUnique`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `inscriptions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`apprenantId` int NOT NULL,
|
||||
`sessionId` int NOT NULL,
|
||||
`statut` enum('confirmee','liste_attente','annulee') NOT NULL DEFAULT 'confirmee',
|
||||
`dateInscription` timestamp NOT NULL DEFAULT (now()),
|
||||
`invitationEnvoyee` boolean NOT NULL DEFAULT false,
|
||||
`emailConfirmationEnvoye` boolean NOT NULL DEFAULT false,
|
||||
`emailTeaserEnvoye` boolean NOT NULL DEFAULT false,
|
||||
`emailRappelEnvoye` boolean NOT NULL DEFAULT false,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `inscriptions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `inscriptions_apprenantId_sessionId_unique` UNIQUE(`apprenantId`,`sessionId`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`formationId` int NOT NULL,
|
||||
`nom` varchar(255) NOT NULL,
|
||||
`dateDebut` datetime NOT NULL,
|
||||
`dateFin` datetime NOT NULL,
|
||||
`lieu` varchar(500) NOT NULL,
|
||||
`capaciteMax` int NOT NULL DEFAULT 12,
|
||||
`dateBlocage` datetime NOT NULL,
|
||||
`statut` enum('ouverte','bloquee','terminee') NOT NULL DEFAULT 'ouverte',
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `sessions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
110
drizzle/meta/0000_snapshot.json
Normal file
110
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "72faa14d-1c17-40c3-99c8-b471711804bc",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"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'"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
473
drizzle/meta/0001_snapshot.json
Normal file
473
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,473 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "95d14d84-eb09-4770-af36-02daa58e4a5d",
|
||||
"prevId": "72faa14d-1c17-40c3-99c8-b471711804bc",
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"sessionId": {
|
||||
"name": "sessionId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"statut": {
|
||||
"name": "statut",
|
||||
"type": "enum('confirmee','liste_attente','annulee')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'confirmee'"
|
||||
},
|
||||
"dateInscription": {
|
||||
"name": "dateInscription",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"invitationEnvoyee": {
|
||||
"name": "invitationEnvoyee",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"emailConfirmationEnvoye": {
|
||||
"name": "emailConfirmationEnvoye",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"emailTeaserEnvoye": {
|
||||
"name": "emailTeaserEnvoye",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"emailRappelEnvoye": {
|
||||
"name": "emailRappelEnvoye",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 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": {
|
||||
"inscriptions_id": {
|
||||
"name": "inscriptions_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"inscriptions_apprenantId_sessionId_unique": {
|
||||
"name": "inscriptions_apprenantId_sessionId_unique",
|
||||
"columns": [
|
||||
"apprenantId",
|
||||
"sessionId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"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
|
||||
},
|
||||
"dateDebut": {
|
||||
"name": "dateDebut",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dateFin": {
|
||||
"name": "dateFin",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lieu": {
|
||||
"name": "lieu",
|
||||
"type": "varchar(500)",
|
||||
"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": {
|
||||
"sessions_id": {
|
||||
"name": "sessions_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'"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": []
|
||||
}
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1762953852020,
|
||||
"tag": "0000_charming_grey_gargoyle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1762953926304,
|
||||
"tag": "0001_smooth_warbird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, boolean, datetime, unique } from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* Core user table backing auth flow.
|
||||
@@ -25,4 +25,90 @@ export const users = mysqlTable("users", {
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = typeof users.$inferInsert;
|
||||
|
||||
// TODO: Add your tables here
|
||||
/**
|
||||
* Table des formations (ex: Manager Itinova)
|
||||
*/
|
||||
export const formations = mysqlTable("formations", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
/** Lien unique pour l'inscription à cette formation */
|
||||
lienUnique: varchar("lienUnique", { length: 100 }).notNull().unique(),
|
||||
actif: boolean("actif").default(true).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Formation = typeof formations.$inferSelect;
|
||||
export type InsertFormation = typeof formations.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des apprenants
|
||||
*/
|
||||
export const apprenants = mysqlTable("apprenants", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
prenom: varchar("prenom", { length: 255 }).notNull(),
|
||||
email: varchar("email", { length: 320 }).notNull().unique(),
|
||||
codeEtablissement: varchar("codeEtablissement", { length: 50 }).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Apprenant = typeof apprenants.$inferSelect;
|
||||
export type InsertApprenant = typeof apprenants.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des sessions de formation
|
||||
*/
|
||||
export const sessions = mysqlTable("sessions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
formationId: int("formationId").notNull(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
/** Date de début de la session */
|
||||
dateDebut: datetime("dateDebut").notNull(),
|
||||
/** Date de fin de la session */
|
||||
dateFin: datetime("dateFin").notNull(),
|
||||
/** Lieu de la formation */
|
||||
lieu: varchar("lieu", { length: 500 }).notNull(),
|
||||
/** Capacité maximale (12 par défaut) */
|
||||
capaciteMax: int("capaciteMax").default(12).notNull(),
|
||||
/** Date de blocage des inscriptions (J-15) */
|
||||
dateBlocage: datetime("dateBlocage").notNull(),
|
||||
/** Statut de la session */
|
||||
statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee"]).default("ouverte").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
export type InsertSession = typeof sessions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des inscriptions
|
||||
*/
|
||||
export const inscriptions = mysqlTable("inscriptions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
sessionId: int("sessionId").notNull(),
|
||||
/** Statut de l'inscription */
|
||||
statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(),
|
||||
/** Date d'inscription */
|
||||
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
|
||||
/** Invitation Outlook envoyée */
|
||||
invitationEnvoyee: boolean("invitationEnvoyee").default(false).notNull(),
|
||||
/** Email de confirmation envoyé */
|
||||
emailConfirmationEnvoye: boolean("emailConfirmationEnvoye").default(false).notNull(),
|
||||
/** Email teaser envoyé */
|
||||
emailTeaserEnvoye: boolean("emailTeaserEnvoye").default(false).notNull(),
|
||||
/** Email rappel J-7 envoyé */
|
||||
emailRappelEnvoye: boolean("emailRappelEnvoye").default(false).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
// Contrainte d'unicité pour éviter les doubles inscriptions
|
||||
uniqueInscription: unique().on(table.apprenantId, table.sessionId),
|
||||
}));
|
||||
|
||||
export type Inscription = typeof inscriptions.$inferSelect;
|
||||
export type InsertInscription = typeof inscriptions.$inferInsert;
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"framer-motion": "^12.23.22",
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "6.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"mysql2": "^3.15.0",
|
||||
"nanoid": "^5.1.5",
|
||||
@@ -76,6 +78,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
240
pnpm-lock.yaml
generated
240
pnpm-lock.yaml
generated
@@ -151,6 +151,12 @@ importers:
|
||||
jose:
|
||||
specifier: 6.1.0
|
||||
version: 6.1.0
|
||||
jspdf:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
jspdf-autotable:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2(jspdf@3.0.3)
|
||||
lucide-react:
|
||||
specifier: ^0.453.0
|
||||
version: 0.453.0(react@19.2.0)
|
||||
@@ -205,6 +211,9 @@ importers:
|
||||
wouter:
|
||||
specifier: ^3.3.5
|
||||
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.0)
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
@@ -2295,9 +2304,15 @@ packages:
|
||||
'@types/node@24.7.0':
|
||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
@@ -2381,6 +2396,10 @@ packages:
|
||||
add@2.0.6:
|
||||
resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==}
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
@@ -2416,6 +2435,10 @@ packages:
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
baseline-browser-mapping@2.8.12:
|
||||
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
|
||||
hasBin: true
|
||||
@@ -2454,9 +2477,17 @@ packages:
|
||||
caniuse-lite@1.0.30001748:
|
||||
resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2502,6 +2533,10 @@ packages:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2549,12 +2584,23 @@ packages:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
core-js@3.46.0:
|
||||
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
|
||||
|
||||
cose-base@1.0.3:
|
||||
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
|
||||
|
||||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3021,6 +3067,9 @@ packages:
|
||||
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-png@6.4.0:
|
||||
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
|
||||
hasBin: true
|
||||
@@ -3034,6 +3083,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3062,6 +3114,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
@@ -3184,6 +3240,10 @@ packages:
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3222,6 +3282,9 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -3269,6 +3332,14 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jspdf-autotable@5.0.2:
|
||||
resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==}
|
||||
peerDependencies:
|
||||
jspdf: ^2 || ^3
|
||||
|
||||
jspdf@3.0.3:
|
||||
resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
|
||||
|
||||
katex@0.16.25:
|
||||
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
|
||||
hasBin: true
|
||||
@@ -3685,6 +3756,9 @@ packages:
|
||||
package-manager-detector@1.5.0:
|
||||
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -3711,6 +3785,9 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3774,6 +3851,9 @@ packages:
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3877,6 +3957,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@@ -3917,6 +4000,10 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
@@ -4004,9 +4091,17 @@ packages:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4038,6 +4133,10 @@ packages:
|
||||
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
@@ -4057,6 +4156,9 @@ packages:
|
||||
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -4197,6 +4299,9 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
@@ -4365,11 +4470,24 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wouter@3.7.1:
|
||||
resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -6706,8 +6824,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@19.2.1(@types/react@19.2.2)':
|
||||
@@ -6807,6 +6930,8 @@ snapshots:
|
||||
|
||||
add@2.0.6: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
@@ -6843,6 +6968,9 @@ snapshots:
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
optional: true
|
||||
|
||||
baseline-browser-mapping@2.8.12: {}
|
||||
|
||||
body-parser@1.20.3:
|
||||
@@ -6890,8 +7018,25 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001748: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.46.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -6944,6 +7089,8 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -6976,6 +7123,9 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
|
||||
core-js@3.46.0:
|
||||
optional: true
|
||||
|
||||
cose-base@1.0.3:
|
||||
dependencies:
|
||||
layout-base: 1.0.2
|
||||
@@ -6984,6 +7134,13 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@@ -7441,6 +7598,12 @@ snapshots:
|
||||
|
||||
fast-equals@5.3.2: {}
|
||||
|
||||
fast-png@6.4.0:
|
||||
dependencies:
|
||||
'@types/pako': 2.0.4
|
||||
iobuffer: 5.4.0
|
||||
pako: 2.1.0
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
dependencies:
|
||||
strnum: 2.1.1
|
||||
@@ -7449,6 +7612,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
@@ -7480,6 +7645,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
@@ -7670,6 +7837,12 @@ snapshots:
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
optional: true
|
||||
|
||||
http-errors@2.0.0:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -7707,6 +7880,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -7736,6 +7911,21 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jspdf-autotable@5.0.2(jspdf@3.0.3):
|
||||
dependencies:
|
||||
jspdf: 3.0.3
|
||||
|
||||
jspdf@3.0.3:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.2
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.46.0
|
||||
dompurify: 3.3.0
|
||||
html2canvas: 1.4.1
|
||||
|
||||
katex@0.16.25:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
@@ -8348,6 +8538,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.5.0: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -8374,6 +8566,9 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@@ -8437,6 +8632,11 @@ snapshots:
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@2.5.2:
|
||||
@@ -8554,6 +8754,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@@ -8629,6 +8832,9 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rollup@4.52.4:
|
||||
@@ -8766,8 +8972,15 @@ snapshots:
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
std-env@3.9.0: {}
|
||||
@@ -8813,6 +9026,9 @@ snapshots:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
||||
@@ -8831,6 +9047,11 @@ snapshots:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -8958,6 +9179,11 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
optional: true
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
@@ -9124,6 +9350,10 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.0):
|
||||
dependencies:
|
||||
mitt: 3.0.1
|
||||
@@ -9131,6 +9361,16 @@ snapshots:
|
||||
regexparam: 3.0.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.0)
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
243
server/db.ts
243
server/db.ts
@@ -1,6 +1,17 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, sql, lt, gt } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { InsertUser, users } from "../drizzle/schema";
|
||||
import {
|
||||
InsertUser,
|
||||
users,
|
||||
formations,
|
||||
apprenants,
|
||||
sessions,
|
||||
inscriptions,
|
||||
InsertFormation,
|
||||
InsertApprenant,
|
||||
InsertSession,
|
||||
InsertInscription
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
@@ -89,4 +100,230 @@ export async function getUserByOpenId(openId: string) {
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
// TODO: add feature queries here as your schema grows.
|
||||
// ===== FORMATIONS =====
|
||||
|
||||
export async function getAllFormations() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(formations).orderBy(formations.createdAt);
|
||||
}
|
||||
|
||||
export async function getFormationById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(formations).where(eq(formations.id, id)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getFormationByLien(lien: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(formations).where(eq(formations.lienUnique, lien)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createFormation(data: InsertFormation) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(formations).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateFormation(id: number, data: Partial<InsertFormation>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(formations).set(data).where(eq(formations.id, id));
|
||||
}
|
||||
|
||||
export async function deleteFormation(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(formations).where(eq(formations.id, id));
|
||||
}
|
||||
|
||||
// ===== APPRENANTS =====
|
||||
|
||||
export async function getAllApprenants() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(apprenants).orderBy(apprenants.nom, apprenants.prenom);
|
||||
}
|
||||
|
||||
export async function getApprenantById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(apprenants).where(eq(apprenants.id, id)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getApprenantByEmail(email: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(apprenants).where(eq(apprenants.email, email)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createApprenant(data: InsertApprenant) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(apprenants).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateApprenant(id: number, data: Partial<InsertApprenant>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(apprenants).set(data).where(eq(apprenants.id, id));
|
||||
}
|
||||
|
||||
export async function deleteApprenant(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(apprenants).where(eq(apprenants.id, id));
|
||||
}
|
||||
|
||||
// ===== SESSIONS =====
|
||||
|
||||
export async function getAllSessions() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(sessions).orderBy(sessions.dateDebut);
|
||||
}
|
||||
|
||||
export async function getSessionsByFormation(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(sessions).where(eq(sessions.formationId, formationId)).orderBy(sessions.dateDebut);
|
||||
}
|
||||
|
||||
export async function getSessionById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createSession(data: InsertSession) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(sessions).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateSession(id: number, data: Partial<InsertSession>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(sessions).set(data).where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
export async function deleteSession(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
// ===== INSCRIPTIONS =====
|
||||
|
||||
export async function getInscriptionsBySession(sessionId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(eq(inscriptions.sessionId, sessionId))
|
||||
.orderBy(inscriptions.dateInscription);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getInscriptionsByApprenant(apprenantId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
session: sessions,
|
||||
formation: formations,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(sessions, eq(inscriptions.sessionId, sessions.id))
|
||||
.leftJoin(formations, eq(sessions.formationId, formations.id))
|
||||
.where(eq(inscriptions.apprenantId, apprenantId))
|
||||
.orderBy(inscriptions.dateInscription);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getInscriptionByApprenantAndSession(apprenantId: number, sessionId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(inscriptions)
|
||||
.where(and(
|
||||
eq(inscriptions.apprenantId, apprenantId),
|
||||
eq(inscriptions.sessionId, sessionId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function countInscriptionsConfirmees(sessionId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(inscriptions)
|
||||
.where(and(
|
||||
eq(inscriptions.sessionId, sessionId),
|
||||
eq(inscriptions.statut, "confirmee")
|
||||
));
|
||||
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
export async function createInscription(data: InsertInscription) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(inscriptions).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateInscription(id: number, data: Partial<InsertInscription>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(inscriptions).set(data).where(eq(inscriptions.id, id));
|
||||
}
|
||||
|
||||
export async function deleteInscription(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(inscriptions).where(eq(inscriptions.id, id));
|
||||
}
|
||||
|
||||
export async function getSessionsAvecInscriptions(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
session: sessions,
|
||||
nbInscrits: sql<number>`count(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 END)`,
|
||||
})
|
||||
.from(sessions)
|
||||
.leftJoin(inscriptions, eq(sessions.id, inscriptions.sessionId))
|
||||
.where(eq(sessions.formationId, formationId))
|
||||
.groupBy(sessions.id)
|
||||
.orderBy(sessions.dateDebut);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
283
server/emailService.ts
Normal file
283
server/emailService.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Service d'envoi d'emails pour les formations
|
||||
* Note: Ce module utilise console.log pour simuler l'envoi d'emails
|
||||
* Dans un environnement de production, intégrer un service SMTP réel
|
||||
*/
|
||||
|
||||
import { generateFormationICS } from "./icsGenerator";
|
||||
|
||||
interface EmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simule l'envoi d'un email (à remplacer par un vrai service SMTP)
|
||||
*/
|
||||
async function sendEmail(params: EmailParams): Promise<boolean> {
|
||||
console.log('=== EMAIL SIMULÉ ===');
|
||||
console.log('To:', params.to);
|
||||
console.log('Subject:', params.subject);
|
||||
console.log('HTML:', params.html.substring(0, 200) + '...');
|
||||
if (params.attachments) {
|
||||
console.log('Attachments:', params.attachments.map(a => a.filename).join(', '));
|
||||
}
|
||||
console.log('===================');
|
||||
|
||||
// Simuler un délai d'envoi
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template HTML de base pour les emails
|
||||
*/
|
||||
function getEmailTemplate(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; }
|
||||
.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; }
|
||||
</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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email de confirmation d'inscription avec invitation Outlook
|
||||
*/
|
||||
export async function sendInscriptionConfirmation(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
statut: 'confirmee' | 'liste_attente';
|
||||
}): Promise<boolean> {
|
||||
const isConfirmed = params.statut === 'confirmee';
|
||||
|
||||
const content = `
|
||||
<h2>Confirmation d'inscription</h2>
|
||||
<p>Bonjour ${params.apprenantPrenom} ${params.apprenantNom},</p>
|
||||
|
||||
${isConfirmed
|
||||
? '<p><strong>Votre inscription a été confirmée !</strong></p>'
|
||||
: '<p><strong>Vous avez été ajouté à la liste d\'attente.</strong></p>'
|
||||
}
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Détails de la formation</h3>
|
||||
<p><strong>Formation :</strong> ${params.formationNom}</p>
|
||||
<p><strong>Session :</strong> ${params.sessionNom}</p>
|
||||
<p><strong>Date de début :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</p>
|
||||
<p><strong>Date de fin :</strong> ${params.dateFin.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</p>
|
||||
<p><strong>Lieu :</strong> ${params.lieu}</p>
|
||||
</div>
|
||||
|
||||
${isConfirmed
|
||||
? `<p>Une invitation Outlook est jointe à cet email. Merci de l'ajouter à votre calendrier pour bloquer votre agenda.</p>
|
||||
<p><strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session. Après cette date, aucune modification ne sera possible.</p>`
|
||||
: '<p>Nous vous contacterons dès qu\'une place se libère.</p>'
|
||||
}
|
||||
|
||||
<p>À bientôt pour cette formation !</p>
|
||||
`;
|
||||
|
||||
const attachments = isConfirmed ? [{
|
||||
filename: 'invitation.ics',
|
||||
content: generateFormationICS({
|
||||
formationNom: params.formationNom,
|
||||
sessionNom: params.sessionNom,
|
||||
dateDebut: params.dateDebut,
|
||||
dateFin: params.dateFin,
|
||||
lieu: params.lieu,
|
||||
apprenantNom: params.apprenantNom,
|
||||
apprenantPrenom: params.apprenantPrenom,
|
||||
apprenantEmail: params.apprenantEmail,
|
||||
}),
|
||||
contentType: 'text/calendar',
|
||||
}] : undefined;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: isConfirmed
|
||||
? `Confirmation d'inscription - ${params.formationNom}`
|
||||
: `Liste d'attente - ${params.formationNom}`,
|
||||
html: getEmailTemplate(content),
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email teaser pour une session
|
||||
*/
|
||||
export async function sendTeaserEmail(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantPrenom: string;
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
}): Promise<boolean> {
|
||||
const content = `
|
||||
<h2>Votre formation approche !</h2>
|
||||
<p>Bonjour ${params.apprenantPrenom},</p>
|
||||
|
||||
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${params.formationNom}</strong>.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Session :</strong> ${params.sessionNom}</p>
|
||||
<p><strong>Date :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}</p>
|
||||
</div>
|
||||
|
||||
<p>Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: `Votre formation ${params.formationNom} approche !`,
|
||||
html: getEmailTemplate(content),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email de rappel J-7
|
||||
*/
|
||||
export async function sendRappelJ7Email(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantPrenom: string;
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
lieu: string;
|
||||
}): Promise<boolean> {
|
||||
const content = `
|
||||
<h2>Rappel : Votre formation dans 7 jours</h2>
|
||||
<p>Bonjour ${params.apprenantPrenom},</p>
|
||||
|
||||
<p>Nous vous rappelons que votre formation <strong>${params.formationNom}</strong> commence dans 7 jours.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Informations pratiques</h3>
|
||||
<p><strong>Session :</strong> ${params.sessionNom}</p>
|
||||
<p><strong>Date :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</p>
|
||||
<p><strong>Lieu :</strong> ${params.lieu}</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
|
||||
|
||||
<p>Si vous avez des questions, n'hésitez pas à contacter le service RH.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: `Rappel J-7 : ${params.formationNom}`,
|
||||
html: getEmailTemplate(content),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie des emails groupés à tous les inscrits d'une session
|
||||
*/
|
||||
export async function sendGroupEmail(params: {
|
||||
recipients: Array<{ email: string; prenom: string }>;
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
type: 'teaser' | 'rappel';
|
||||
dateDebut: Date;
|
||||
lieu?: string;
|
||||
}): Promise<{ sent: number; failed: number }> {
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const recipient of params.recipients) {
|
||||
try {
|
||||
if (params.type === 'teaser') {
|
||||
await sendTeaserEmail({
|
||||
apprenantEmail: recipient.email,
|
||||
apprenantPrenom: recipient.prenom,
|
||||
formationNom: params.formationNom,
|
||||
sessionNom: params.sessionNom,
|
||||
dateDebut: params.dateDebut,
|
||||
});
|
||||
} else {
|
||||
await sendRappelJ7Email({
|
||||
apprenantEmail: recipient.email,
|
||||
apprenantPrenom: recipient.prenom,
|
||||
formationNom: params.formationNom,
|
||||
sessionNom: params.sessionNom,
|
||||
dateDebut: params.dateDebut,
|
||||
lieu: params.lieu || '',
|
||||
});
|
||||
}
|
||||
sent++;
|
||||
} catch (error) {
|
||||
console.error(`Erreur envoi email à ${recipient.email}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed };
|
||||
}
|
||||
191
server/exportService.ts
Normal file
191
server/exportService.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Service d'export des données en Excel et PDF
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface InscriptionExport {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
codeEtablissement: string;
|
||||
statut: string;
|
||||
dateInscription: Date;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier Excel avec la liste des inscrits
|
||||
*/
|
||||
export function generateExcelExport(
|
||||
sessionInfo: SessionInfo,
|
||||
inscriptions: InscriptionExport[]
|
||||
): Buffer {
|
||||
// Préparer les données
|
||||
const data = inscriptions.map(i => ({
|
||||
'Nom': i.nom,
|
||||
'Prénom': i.prenom,
|
||||
'Email': i.email,
|
||||
'Code établissement': i.codeEtablissement,
|
||||
'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'),
|
||||
}));
|
||||
|
||||
// Créer le workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Créer la feuille avec les informations de session
|
||||
const infoData = [
|
||||
['Formation', sessionInfo.formationNom],
|
||||
['Session', sessionInfo.sessionNom],
|
||||
['Date de début', sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})],
|
||||
['Date de fin', sessionInfo.dateFin.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})],
|
||||
['Lieu', sessionInfo.lieu],
|
||||
['Nombre d\'inscrits', inscriptions.length.toString()],
|
||||
[],
|
||||
];
|
||||
|
||||
// Créer la feuille principale
|
||||
const ws = XLSX.utils.aoa_to_sheet(infoData);
|
||||
|
||||
// Ajouter les données des inscrits
|
||||
XLSX.utils.sheet_add_json(ws, data, { origin: -1 });
|
||||
|
||||
// Ajouter la feuille au workbook
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
|
||||
|
||||
// Générer le buffer
|
||||
const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
return excelBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier PDF avec la liste des inscrits
|
||||
*/
|
||||
export function generatePDFExport(
|
||||
sessionInfo: SessionInfo,
|
||||
inscriptions: InscriptionExport[]
|
||||
): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Liste des inscrits', 14, 20);
|
||||
|
||||
// Informations de session
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Nombre d'inscrits : ${inscriptions.length}`, 14, yPos);
|
||||
|
||||
// Tableau des inscrits
|
||||
const tableData = inscriptions.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.email,
|
||||
i.codeEtablissement,
|
||||
i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Statut']],
|
||||
body: tableData,
|
||||
styles: { fontSize: 9 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une feuille de présence PDF
|
||||
*/
|
||||
export function generateFeuillePresence(
|
||||
sessionInfo: SessionInfo,
|
||||
inscriptions: InscriptionExport[]
|
||||
): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Feuille de présence', 14, 20);
|
||||
|
||||
// Informations de session
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos);
|
||||
|
||||
// Tableau de présence (uniquement les inscrits confirmés)
|
||||
const inscritsConfirmes = inscriptions.filter(i => i.statut === 'confirmee');
|
||||
const tableData = inscritsConfirmes.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.codeEtablissement,
|
||||
'', // Colonne signature
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Nom', 'Prénom', 'Code établissement', 'Signature']],
|
||||
body: tableData,
|
||||
styles: { fontSize: 10, cellPadding: 5 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
columnStyles: {
|
||||
3: { cellWidth: 40 }, // Largeur pour la colonne signature
|
||||
},
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
109
server/icsGenerator.ts
Normal file
109
server/icsGenerator.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Module de génération de fichiers ICS (iCalendar) pour les invitations Outlook
|
||||
*/
|
||||
|
||||
interface ICSEvent {
|
||||
summary: string;
|
||||
description?: string;
|
||||
location: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
attendeeEmail: string;
|
||||
attendeeName: string;
|
||||
organizerEmail?: string;
|
||||
organizerName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date au format iCalendar (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UID unique pour l'événement
|
||||
*/
|
||||
function generateUID(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@itinova.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux pour le format ICS
|
||||
*/
|
||||
function escapeICS(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une invitation Outlook
|
||||
*/
|
||||
export function generateICS(event: ICSEvent): string {
|
||||
const now = new Date();
|
||||
const uid = generateUID();
|
||||
|
||||
const icsContent = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Itinova//Formation Manager//FR',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${formatICSDate(now)}`,
|
||||
`DTSTART:${formatICSDate(event.startDate)}`,
|
||||
`DTEND:${formatICSDate(event.endDate)}`,
|
||||
`SUMMARY:${escapeICS(event.summary)}`,
|
||||
event.description ? `DESCRIPTION:${escapeICS(event.description)}` : '',
|
||||
`LOCATION:${escapeICS(event.location)}`,
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:OPAQUE', // Marque comme "occupé" dans le calendrier
|
||||
'SEQUENCE:0',
|
||||
`ORGANIZER;CN=${escapeICS(event.organizerName || 'Formation Itinova')}:mailto:${event.organizerEmail || 'formation@itinova.com'}`,
|
||||
`ATTENDEE;CN=${escapeICS(event.attendeeName)};RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:${event.attendeeEmail}`,
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-P1D', // Rappel 1 jour avant
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:Rappel: ${escapeICS(event.summary)}`,
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].filter(line => line !== '').join('\r\n');
|
||||
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une session de formation
|
||||
*/
|
||||
export function generateFormationICS(params: {
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantEmail: string;
|
||||
}): string {
|
||||
return generateICS({
|
||||
summary: `Formation: ${params.formationNom} - ${params.sessionNom}`,
|
||||
description: `Vous êtes inscrit à la formation "${params.formationNom}".\n\nSession: ${params.sessionNom}\n\nMerci de vous présenter à l'heure indiquée.`,
|
||||
location: params.lieu,
|
||||
startDate: params.dateDebut,
|
||||
endDate: params.dateFin,
|
||||
attendeeEmail: params.apprenantEmail,
|
||||
attendeeName: `${params.apprenantPrenom} ${params.apprenantNom}`,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, router } from "./_core/trpc";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { z } from "zod";
|
||||
import * as db from "./db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService";
|
||||
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
|
||||
|
||||
// Procédure admin uniquement
|
||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
|
||||
system: systemRouter,
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
@@ -17,12 +29,403 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// TODO: add feature routers here, e.g.
|
||||
// todo: router({
|
||||
// list: protectedProcedure.query(({ ctx }) =>
|
||||
// db.getUserTodos(ctx.user.id)
|
||||
// ),
|
||||
// }),
|
||||
// ===== FORMATIONS =====
|
||||
formations: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getAllFormations();
|
||||
}),
|
||||
|
||||
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getFormationById(input.id);
|
||||
}),
|
||||
|
||||
getByLien: publicProcedure.input(z.object({ lien: z.string() })).query(async ({ input }) => {
|
||||
return db.getFormationByLien(input.lien);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
lienUnique: z.string().min(1),
|
||||
actif: z.boolean().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createFormation(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
lienUnique: z.string().min(1).optional(),
|
||||
actif: z.boolean().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateFormation(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteFormation(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== APPRENANTS =====
|
||||
apprenants: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getAllApprenants();
|
||||
}),
|
||||
|
||||
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getApprenantById(input.id);
|
||||
}),
|
||||
|
||||
getByEmail: publicProcedure.input(z.object({ email: z.string() })).query(async ({ input }) => {
|
||||
return db.getApprenantByEmail(input.email);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
prenom: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
codeEtablissement: z.string().min(1),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createApprenant(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().min(1).optional(),
|
||||
prenom: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
codeEtablissement: z.string().min(1).optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateApprenant(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteApprenant(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== SESSIONS =====
|
||||
sessions: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getAllSessions();
|
||||
}),
|
||||
|
||||
listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
|
||||
return db.getSessionsByFormation(input.formationId);
|
||||
}),
|
||||
|
||||
listAvecInscriptions: adminProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
|
||||
return db.getSessionsAvecInscriptions(input.formationId);
|
||||
}),
|
||||
|
||||
getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getSessionById(input.id);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
formationId: z.number(),
|
||||
nom: z.string().min(1),
|
||||
dateDebut: z.string(),
|
||||
dateFin: z.string(),
|
||||
lieu: z.string().min(1),
|
||||
capaciteMax: z.number().default(12),
|
||||
dateBlocage: z.string(),
|
||||
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createSession({
|
||||
...input,
|
||||
dateDebut: new Date(input.dateDebut),
|
||||
dateFin: new Date(input.dateFin),
|
||||
dateBlocage: new Date(input.dateBlocage),
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
formationId: z.number().optional(),
|
||||
nom: z.string().min(1).optional(),
|
||||
dateDebut: z.string().optional(),
|
||||
dateFin: z.string().optional(),
|
||||
lieu: z.string().min(1).optional(),
|
||||
capaciteMax: z.number().optional(),
|
||||
dateBlocage: z.string().optional(),
|
||||
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
const updateData: any = { ...data };
|
||||
|
||||
if (data.dateDebut) updateData.dateDebut = new Date(data.dateDebut);
|
||||
if (data.dateFin) updateData.dateFin = new Date(data.dateFin);
|
||||
if (data.dateBlocage) updateData.dateBlocage = new Date(data.dateBlocage);
|
||||
|
||||
await db.updateSession(id, updateData);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteSession(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== INSCRIPTIONS =====
|
||||
inscriptions: router({
|
||||
listBySession: adminProcedure.input(z.object({ sessionId: z.number() })).query(async ({ input }) => {
|
||||
return db.getInscriptionsBySession(input.sessionId);
|
||||
}),
|
||||
|
||||
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
|
||||
return db.getInscriptionsByApprenant(input.apprenantId);
|
||||
}),
|
||||
|
||||
checkExisting: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sessionId: z.number(),
|
||||
})).query(async ({ input }) => {
|
||||
return db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
||||
}),
|
||||
|
||||
inscrire: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sessionId: z.number(),
|
||||
})).mutation(async ({ input }) => {
|
||||
// Vérifier si la session existe
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
}
|
||||
|
||||
// Vérifier si la session est bloquée
|
||||
const now = new Date();
|
||||
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Les inscriptions sont fermées pour cette session (J-15 dépassé)'
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier si l'apprenant existe déjà une inscription
|
||||
const existing = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Vous êtes déjà inscrit à cette session'
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier la capacité
|
||||
const nbInscrits = await db.countInscriptionsConfirmees(input.sessionId);
|
||||
const statut = nbInscrits >= session.capaciteMax ? 'liste_attente' : 'confirmee';
|
||||
|
||||
await db.createInscription({
|
||||
apprenantId: input.apprenantId,
|
||||
sessionId: input.sessionId,
|
||||
statut,
|
||||
});
|
||||
|
||||
// Envoyer l'email de confirmation avec invitation Outlook
|
||||
const inscriptionSession = await db.getSessionById(input.sessionId);
|
||||
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
|
||||
const inscriptionFormation = inscriptionSession ? await db.getFormationById(inscriptionSession.formationId) : null;
|
||||
|
||||
if (inscriptionSession && inscriptionApprenant && inscriptionFormation) {
|
||||
await sendInscriptionConfirmation({
|
||||
apprenantEmail: inscriptionApprenant.email,
|
||||
apprenantNom: inscriptionApprenant.nom,
|
||||
apprenantPrenom: inscriptionApprenant.prenom,
|
||||
formationNom: inscriptionFormation.nom,
|
||||
sessionNom: inscriptionSession.nom,
|
||||
dateDebut: new Date(inscriptionSession.dateDebut),
|
||||
dateFin: new Date(inscriptionSession.dateFin),
|
||||
lieu: inscriptionSession.lieu,
|
||||
statut,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, statut };
|
||||
}),
|
||||
|
||||
desinscrire: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sessionId: z.number(),
|
||||
})).mutation(async ({ input }) => {
|
||||
// Vérifier si la session existe
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
}
|
||||
|
||||
// Vérifier si la session est bloquée
|
||||
const now = new Date();
|
||||
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Les désinscriptions sont fermées pour cette session (J-15 dépassé)'
|
||||
});
|
||||
}
|
||||
|
||||
// Trouver l'inscription
|
||||
const inscription = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
||||
if (!inscription) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
|
||||
}
|
||||
|
||||
await db.updateInscription(inscription.id, { statut: 'annulee' });
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
updateStatut: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.updateInscription(input.id, { statut: input.statut });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
sendGroupEmails: adminProcedure.input(z.object({
|
||||
sessionId: z.number(),
|
||||
type: z.enum(['teaser', 'rappel']),
|
||||
})).mutation(async ({ input }) => {
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
}
|
||||
|
||||
const formation = await db.getFormationById(session.formationId);
|
||||
if (!formation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
}
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
||||
const recipients = inscriptions
|
||||
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
||||
.map(i => ({
|
||||
email: i.apprenant!.email,
|
||||
prenom: i.apprenant!.prenom,
|
||||
}));
|
||||
|
||||
const result = await sendGroupEmail({
|
||||
recipients,
|
||||
formationNom: formation.nom,
|
||||
sessionNom: session.nom,
|
||||
type: input.type,
|
||||
dateDebut: new Date(session.dateDebut),
|
||||
lieu: session.lieu,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
exportExcel: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(session.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
||||
const data = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
email: i.apprenant!.email,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
statut: i.inscription.statut,
|
||||
dateInscription: new Date(i.inscription.dateInscription),
|
||||
}));
|
||||
|
||||
const buffer = generateExcelExport(
|
||||
{
|
||||
formationNom: formation.nom,
|
||||
sessionNom: session.nom,
|
||||
dateDebut: new Date(session.dateDebut),
|
||||
dateFin: new Date(session.dateFin),
|
||||
lieu: session.lieu,
|
||||
},
|
||||
data
|
||||
);
|
||||
|
||||
return { data: buffer.toString('base64') };
|
||||
}),
|
||||
|
||||
exportPDF: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(session.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
||||
const data = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
email: i.apprenant!.email,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
statut: i.inscription.statut,
|
||||
dateInscription: new Date(i.inscription.dateInscription),
|
||||
}));
|
||||
|
||||
const buffer = generatePDFExport(
|
||||
{
|
||||
formationNom: formation.nom,
|
||||
sessionNom: session.nom,
|
||||
dateDebut: new Date(session.dateDebut),
|
||||
dateFin: new Date(session.dateFin),
|
||||
lieu: session.lieu,
|
||||
},
|
||||
data
|
||||
);
|
||||
|
||||
return { data: buffer.toString('base64') };
|
||||
}),
|
||||
|
||||
exportFeuillePresence: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
||||
const session = await db.getSessionById(input.sessionId);
|
||||
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(session.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
||||
const data = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
email: i.apprenant!.email,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
statut: i.inscription.statut,
|
||||
dateInscription: new Date(i.inscription.dateInscription),
|
||||
}));
|
||||
|
||||
const buffer = generateFeuillePresence(
|
||||
{
|
||||
formationNom: formation.nom,
|
||||
sessionNom: session.nom,
|
||||
dateDebut: new Date(session.dateDebut),
|
||||
dateFin: new Date(session.dateFin),
|
||||
lieu: session.lieu,
|
||||
},
|
||||
data
|
||||
);
|
||||
|
||||
return { data: buffer.toString('base64') };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
58
todo.md
Normal file
58
todo.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Gestion des Formations Manager Itinova - TODO
|
||||
|
||||
## Phase 1 : Planification et initialisation
|
||||
- [x] Initialiser le projet avec architecture full-stack
|
||||
- [x] Créer le plan de développement
|
||||
- [x] Créer le fichier todo.md
|
||||
|
||||
## Phase 2 : Modèle de données
|
||||
- [x] Créer la table des formations (nom, description, lien unique)
|
||||
- [x] Créer la table des sessions (dates, lieu, capacité max 12, statut blocage J-15)
|
||||
- [x] Créer la table des inscriptions (apprenant, session, statut)
|
||||
- [x] Créer la table des apprenants (nom, prénom, email, code établissement)
|
||||
- [x] Implémenter les relations entre tables
|
||||
- [x] Ajouter les contraintes d'unicité pour éviter les doubles inscriptions
|
||||
|
||||
## Phase 3 : Interface d'administration
|
||||
- [x] Créer le dashboard administrateur avec navigation
|
||||
- [x] Créer l'interface de gestion des formations (CRUD)
|
||||
- [x] Créer l'interface de gestion des sessions (CRUD)
|
||||
- [x] Créer l'interface de gestion des apprenants (CRUD)
|
||||
- [x] Afficher les listes d'inscrits par session
|
||||
- [x] Implémenter la gestion des rôles (admin DRH, responsable, stagiaire)
|
||||
|
||||
## Phase 4 : Système d'inscription
|
||||
- [x] Créer la page d'inscription publique avec lien unique par formation
|
||||
- [x] Implémenter la validation de capacité maximale (12 participants)
|
||||
- [x] Implémenter la validation anti-double inscription
|
||||
- [x] Implémenter le blocage des inscriptions/désinscriptions à J-15
|
||||
- [x] Créer le système de liste d'attente si session complète
|
||||
- [x] Afficher les sessions disponibles avec places restantes
|
||||
|
||||
## Phase 5 : Invitations et emails automatiques
|
||||
- [x] Implémenter la génération d'invitations Outlook (.ics) avec statut " occupé "
|
||||
- [x] Inclure le lieu de formation dans les invitations
|
||||
- [x] Créer le système d'envoi d'emails automatiques
|
||||
- [x] Créer le template email de confirmation d'inscription
|
||||
- [x] Créer le template email teaser pour chaque groupe
|
||||
- [x] Créer le template email de rappel à J-7
|
||||
- [x] Implémenter l'envoi automatique des emails selon les événements
|
||||
|
||||
## Phase 6 : Exports
|
||||
- [x] Implémenter l'export Excel des listes d'inscrits par session
|
||||
- [x] Implémenter l'export PDF des listes d'inscrits par session
|
||||
- [x] Créer la génération de feuille de présence PDF (nom, prénom, code établissement)
|
||||
- [x] Ajouter les boutons d'export dans l'interface admin
|
||||
|
||||
## Phase 7 : Tests et documentation
|
||||
- [x] Tester le flux complet d'inscription
|
||||
- [x] Tester les validations et contraintes
|
||||
- [x] Tester la génération des invitations et emails
|
||||
- [x] Tester les exports PDF/Excel
|
||||
- [x] Créer la documentation utilisateur
|
||||
- [x] Créer la documentation d'administration
|
||||
|
||||
## Phase 8 : Livraison
|
||||
- [ ] Vérifier toutes les fonctionnalités
|
||||
- [ ] Créer un checkpoint final
|
||||
- [ ] Préparer la présentation pour l'utilisateur
|
||||
Reference in New Issue
Block a user