diff --git a/client/src/App.tsx b/client/src/App.tsx index dce7178..3709e59 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; +import EmargementFormateurScan from "./pages/EmargementFormateurScan"; import LoginChoice from "./pages/LoginChoice"; import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import Admin from "./pages/Admin"; @@ -48,8 +49,9 @@ function Router() { return ( - - + + + diff --git a/client/src/pages/EmargementFormateurScan.tsx b/client/src/pages/EmargementFormateurScan.tsx new file mode 100644 index 0000000..a70ac2a --- /dev/null +++ b/client/src/pages/EmargementFormateurScan.tsx @@ -0,0 +1,220 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { trpc } from "@/lib/trpc"; +import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "wouter"; +import { toast } from "sonner"; +import SignaturePadComponent from "@/components/SignaturePad"; + +export default function EmargementFormateurScan() { + const { token } = useParams<{ token: string }>(); + const [email, setEmail] = useState(""); + const [emailConfirmed, setEmailConfirmed] = useState(false); + const [selectedPeriode, setSelectedPeriode] = useState<"matin" | "apres_midi">("matin"); + const [success, setSuccess] = useState(false); + const [heureEnregistrement, setHeureEnregistrement] = useState(null); + const [error, setError] = useState(null); + const [showSignaturePad, setShowSignaturePad] = useState(false); + + // Valider la présence formateur + const { mutate: validerPresence, isPending: validating } = trpc.presences.validerFormateur.useMutation({ + onSuccess: () => { + const now = new Date(); + setHeureEnregistrement(now); + setSuccess(true); + toast.success("Présence validée avec succès !"); + }, + onError: (error) => { + setError(error.message); + toast.error("Erreur lors de la validation", { + description: error.message, + }); + }, + }); + + const handleSaveSignature = (dataUrl: string) => { + setShowSignaturePad(false); + // Valider automatiquement après la signature + validerPresence({ + token: token || "", + email, + periode: selectedPeriode, + signatureDataUrl: dataUrl, + }); + }; + + const handleSearchEmail = () => { + if (!email || !email.includes("@")) { + toast.error("Veuillez saisir un email valide"); + return; + } + setEmailConfirmed(true); + }; + + const handleValider = () => { + setShowSignaturePad(true); + }; + + if (!token) { + return ( +
+ + + + + Lien invalide + + Le lien d'émargement est invalide ou a expiré. + + +
+ ); + } + + if (showSignaturePad) { + return ( +
+ + + Signature de présence formateur + + Signez pour valider votre présence en tant que formateur + + + + setShowSignaturePad(false)} + /> + + +
+ ); + } + + if (success) { + return ( +
+ + + + + Présence validée + + + Votre présence a été enregistrée avec succès + {heureEnregistrement && ( + + Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")} + + )} + + + +
+ ); + } + + if (error) { + return ( +
+ + + + + Erreur + + {error} + + + + + +
+ ); + } + + return ( +
+ + + Émargement formateur + Validez votre présence en tant que formateur + + + {!emailConfirmed ? ( +
+
+ + setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearchEmail(); + } + }} + /> +
+ +
+ ) : ( +
+
+
+ 👨‍🏫 + Mode formateur +
+

{email}

+
+ +
+ + +
+ + +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/EmargementScan.tsx b/client/src/pages/EmargementScan.tsx index 4714e58..27c7b1b 100644 --- a/client/src/pages/EmargementScan.tsx +++ b/client/src/pages/EmargementScan.tsx @@ -38,13 +38,7 @@ export default function EmargementScan() { // Récupérer les inscriptions de l'apprenant avec les dates const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery( { apprenantId: apprenant?.id || 0 }, - { enabled: !!apprenant } - ); - - // Récupérer les séquences pour le token (pour les formateurs) - const { data: sequenceData } = trpc.presences.getSequenceByToken.useQuery( - { token: token || "" }, - { enabled: emailConfirmed && isFormateur && !!token } + { enabled: !!apprenant && !isFormateur } ); // Récupérer les dates de formation pour l'inscription sélectionnée @@ -86,129 +80,111 @@ export default function EmargementScan() { setIsFormateur(false); setEmailConfirmed(true); } else { - // Peut-être un formateur, on continue quand même + // C'est un formateur setIsFormateur(true); setEmailConfirmed(true); + // Pour les formateurs, on passe directement à la signature + setShowSignaturePad(true); + } + }; + + const handleValiderWithSignature = (signatureDataUrl: string) => { + if (isFormateur) { + // Pour les formateurs : validation directe avec le token + validerPresence({ + token: token || "", + email, + periode: selectedPeriode, + signatureDataUrl, + }); + } else { + // Pour les apprenants : validation classique + if (!selectedDateId) { + toast.error("Veuillez sélectionner une date"); + return; + } + + validerPresence({ + token: token || "", + dateId: selectedDateId, + periode: selectedPeriode, + email, + signatureDataUrl, + }); } }; const handleValider = () => { if (isFormateur) { - // Pour un formateur, on a juste besoin de la date et de la période - if (!selectedDateId) { - toast.error("Veuillez sélectionner une date de formation"); - return; - } + // Pour les formateurs, on affiche le pad de signature + setShowSignaturePad(true); } else { - // Pour un apprenant, on a besoin de l'inscription et de la date - if (!selectedInscriptionId || !selectedDateId) { - toast.error("Veuillez sélectionner une date de formation"); - return; - } - } - - // Afficher le pad de signature - setShowSignaturePad(true); - }; - - const handleValiderWithSignature = (dataUrl: string) => { - if (isFormateur) { - // Pour un formateur + // Pour les apprenants, vérifier les champs requis if (!selectedDateId) { - toast.error("Veuillez sélectionner une date de formation"); + toast.error("Veuillez sélectionner une date"); return; } - - validerPresence({ - token, - email, - dateFormationId: selectedDateId, - periode: selectedPeriode, - modeValidation: "qrcode", - signatureDataUrl: dataUrl, - }); - } else { - // Pour un apprenant - if (!selectedInscriptionId || !selectedDateId) { - toast.error("Veuillez sélectionner une date de formation"); - return; - } - - validerPresence({ - token, - email, - inscriptionId: selectedInscriptionId, - dateFormationId: selectedDateId, - periode: selectedPeriode, - modeValidation: "qrcode", - signatureDataUrl: dataUrl, - }); + setShowSignaturePad(true); } }; - // Déterminer les dates disponibles - const datesDisponibles = isFormateur - ? (sequenceData?.dates || []) - : ((selectedInscription?.sequence as any)?.dates || []); + if (!token) { + return ( +
+ + + + + Lien invalide + + Le lien d'émargement est invalide ou a expiré. + + +
+ ); + } + + if (showSignaturePad) { + return ( +
+ + + Signature de présence + + {isFormateur + ? "Signez pour valider votre présence en tant que formateur" + : "Signez pour valider votre présence"} + + + + setShowSignaturePad(false)} + /> + + +
+ ); + } if (success) { return ( -
- - -
- -
- - Présence validée ! +
+ + + + + Présence validée - + Votre présence a été enregistrée avec succès + {heureEnregistrement && ( + + Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")} + + )} - - {heureEnregistrement && ( -
-

- 🕒 Heure d'enregistrement -

-

- {heureEnregistrement.toLocaleTimeString("fr-FR", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} -

-

- {heureEnregistrement.toLocaleDateString("fr-FR", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - })} -

-
- )} -

- Merci d'avoir confirmé votre présence. Vous pouvez maintenant fermer cette page. -

- -
); @@ -216,26 +192,17 @@ export default function EmargementScan() { if (error) { return ( -
- - -
- -
- Erreur - {error} +
+ + + + + Erreur + + {error} - - @@ -246,23 +213,20 @@ export default function EmargementScan() { return (
- + - Émargement numérique - - Validez votre présence en renseignant vos informations - + Émargement numérique + Validez votre présence en renseignant vos informations - - {/* Étape 1: Saisie de l'email */} - {!emailConfirmed && ( -
-
- + + {!emailConfirmed ? ( +
+
+ setEmail(e.target.value)} onKeyDown={(e) => { @@ -276,210 +240,140 @@ export default function EmargementScan() { Continuer
- )} - - {/* Étape 2: Sélection pour apprenant */} - {emailConfirmed && !isFormateur && apprenant && !success && ( + ) : isFormateur ? (
-
-

- {apprenant.prenom} {apprenant.nom} -

-

{apprenant.email}

+
+
+ 👨‍🏫 + Mode formateur +
+

{email}

+
+ +
+ + +
+ + +
+ ) : ( +
+
+

{apprenant?.nom} {apprenant?.prenom}

+

{email}

{inscriptions && inscriptions.length > 0 ? ( <> -
- +
+
- {selectedInscriptionId && datesDisponibles.length > 0 && ( - <> -
- - -
- -
- - -
- - - - )} - - ) : ( -

- Aucune inscription trouvée pour cet email -

- )} -
- )} - - {/* Étape 2: Sélection pour formateur */} - {emailConfirmed && isFormateur && !success && ( -
-
-

- 👨‍🏫 Mode formateur -

-

{email}

-
- - {sequenceData && sequenceData.dates && sequenceData.dates.length > 0 ? ( - <> -
- -
-

{sequenceData.nom}

-

{sequenceData.lieu}

+ {selectedInscription && ( +
+ +
-
+ )} -
- +
+
- {selectedDateId && ( - <> -
- - -
- - - - )} + ) : ( -
- -

Chargement des informations...

+
+

Aucune inscription trouvée pour cet email.

)}
)} - - {/* Dialog de signature */} - {showSignaturePad && ( -
- - - Signature - - Signez dans le cadre ci-dessous pour valider votre présence - - - - setShowSignaturePad(false)} - /> - - -
- )}
); } diff --git a/client/src/pages/FormateurEmargement.tsx b/client/src/pages/FormateurEmargement.tsx index 78a9cc9..d341359 100644 --- a/client/src/pages/FormateurEmargement.tsx +++ b/client/src/pages/FormateurEmargement.tsx @@ -26,13 +26,14 @@ export default function FormateurEmargement() { const { user } = useAuth(); const [selectedSequenceId, setSelectedSequenceId] = useState(null); const [showQRDialog, setShowQRDialog] = useState(false); + const [showQRFormateurDialog, setShowQRFormateurDialog] = useState(false); const [lastRefreshTime, setLastRefreshTime] = useState(new Date()); const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0); // Récupérer toutes les séquences (filtrées côté serveur pour les formateurs) const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery(); - // Générer le QR code + // Générer le QR code apprenants const { data: qrData, mutate: generateQR, isPending: generatingQR } = trpc.presences.generateQRCode.useMutation({ onSuccess: () => { setShowQRDialog(true); @@ -44,6 +45,18 @@ export default function FormateurEmargement() { }, }); + // Générer le QR code formateur + const { data: qrFormateurData, mutate: generateQRFormateur, isPending: generatingQRFormateur } = trpc.presences.generateQRCodeFormateur.useMutation({ + onSuccess: () => { + setShowQRFormateurDialog(true); + }, + onError: (error) => { + toast.error("Erreur lors de la génération du QR code formateur", { + description: error.message, + }); + }, + }); + // Récupérer les inscrits avec les dates de formation const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery( { sequenceId: selectedSequenceId || 0 }, @@ -121,6 +134,15 @@ export default function FormateurEmargement() { generateQR({ sequenceId: selectedSequenceId }); }; + const handleGenerateQRFormateur = () => { + if (!selectedSequenceId) { + toast.error("Veuillez sélectionner une séquence"); + return; + } + // Générer le QR code formateur + generateQRFormateur({ sequenceId: selectedSequenceId }); + }; + const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => { validerPresence({ inscriptionId, @@ -209,7 +231,7 @@ export default function FormateurEmargement() { {selectedSequenceId && (
-
+
+ @@ -451,13 +486,13 @@ export default function FormateurEmargement() { )} - {/* Dialog QR Code */} + {/* Dialog QR Code Apprenants */} - QR Code d'émargement + QR Code d'émargement apprenants - Les apprenants et formateurs doivent scanner ce QR code pour valider leur présence avec signature tactile + Les apprenants doivent scanner ce QR code pour valider leur présence avec signature tactile {qrData && ( @@ -475,6 +510,38 @@ export default function FormateurEmargement() { + {/* Dialog QR Code Formateur */} + + + + QR Code d'émargement formateur + + Scannez ce QR code avec votre smartphone pour émarger en tant que formateur avec signature tactile + + + {qrFormateurData && ( +
+ QR Code Formateur +
+

👨‍🏫 Mode formateur +

+

+ Ce QR code vous redirigera vers une page dédiée pour l'émargement formateur. + Vous pourrez signer votre présence (matin/après-midi) directement depuis votre smartphone. +

+
+

+ Affichez ce QR code en plein écran pour faciliter le scan +

+
+ )} +
+
+ {/* Dialog Signature */} diff --git a/formation-manager-update-20260202-150825.tar.gz b/formation-manager-update-20260202-150825.tar.gz new file mode 100644 index 0000000..8020aa1 Binary files /dev/null and b/formation-manager-update-20260202-150825.tar.gz differ diff --git a/server/presenceDb-formateur-fix.ts b/server/presenceDb-formateur-fix.ts new file mode 100644 index 0000000..f9a2f78 --- /dev/null +++ b/server/presenceDb-formateur-fix.ts @@ -0,0 +1,72 @@ +// Correction pour validerPresenceFormateur - à intégrer dans presenceDb.ts ligne 189-256 + +export async function validerPresenceFormateur(params: { + formateurId: number; + sequenceId: number; + dateFormationId?: number; + periode: "matin" | "apres_midi"; + modeValidation: "qrcode" | "manuel"; + commentaire?: string; + signatureUrl?: string; + signatureS3Key?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Si dateFormationId est fourni, vérifier la date + if (params.dateFormationId) { + const dateFormation = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.id, params.dateFormationId)) + .limit(1); + + if (dateFormation.length === 0) { + throw new Error("Date de formation introuvable"); + } + + // Vérifier que la date actuelle est le jour de la formation ou après + const now = new Date(); + const dateDebut = new Date(dateFormation[0].dateDebut); + + // Réinitialiser les heures pour comparer uniquement les dates + now.setHours(0, 0, 0, 0); + dateDebut.setHours(0, 0, 0, 0); + + if (now < dateDebut) { + throw new Error("Vous ne pouvez pas émarger avant la date de formation"); + } + + // Vérifier si la présence existe déjà pour cette période + const presenceExistante = await db + .select() + .from(presences) + .where( + and( + eq(presences.formateurId, params.formateurId), + eq(presences.dateFormationId, params.dateFormationId), + sql`${presences.periode} = ${params.periode}`, + sql`${presences.typeUtilisateur} = 'formateur'` + ) + ); + + if (presenceExistante.length > 0) { + throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); + } + } + + // Créer la présence formateur + await db.insert(presences).values({ + typeUtilisateur: "formateur", + formateurId: params.formateurId, + dateFormationId: params.dateFormationId || null, + periode: params.periode, + heurePresence: new Date(), + modeValidation: params.modeValidation, + commentaire: params.commentaire, + signatureUrl: params.signatureUrl, + signatureS3Key: params.signatureS3Key, + }); + + return { success: true }; +} diff --git a/server/presenceDb.ts b/server/presenceDb.ts index e2cfbde..6a6797f 100644 --- a/server/presenceDb.ts +++ b/server/presenceDb.ts @@ -189,7 +189,7 @@ export async function supprimerPresence(presenceId: number) { export async function validerPresenceFormateur(params: { formateurId: number; sequenceId: number; - dateFormationId: number; + dateFormationId?: number; periode: "matin" | "apres_midi"; modeValidation: "qrcode" | "manuel"; commentaire?: string; diff --git a/server/presenceDb.ts.backup b/server/presenceDb.ts.backup new file mode 100644 index 0000000..6a6797f --- /dev/null +++ b/server/presenceDb.ts.backup @@ -0,0 +1,287 @@ +import { eq, and, sql } from "drizzle-orm"; +import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema"; +import { getDb } from "./db"; + +/** + * Valider la présence d'un apprenant (par QR code ou manuellement) + */ +export async function validerPresence(params: { + inscriptionId: number; + dateFormationId: number; + periode: "matin" | "apres_midi"; + modeValidation: "qrcode" | "manuel"; + validateurId?: number; + commentaire?: string; + signatureUrl?: string; + signatureS3Key?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Récupérer la date de formation + const dateFormation = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.id, params.dateFormationId)) + .limit(1); + + if (dateFormation.length === 0) { + throw new Error("Date de formation introuvable"); + } + + // Vérifier que la date actuelle est le jour de la formation ou après + const now = new Date(); + const dateDebut = new Date(dateFormation[0].dateDebut); + + // Réinitialiser les heures pour comparer uniquement les dates + now.setHours(0, 0, 0, 0); + dateDebut.setHours(0, 0, 0, 0); + + if (now < dateDebut) { + throw new Error("Vous ne pouvez pas émarger avant la date de formation"); + } + + // Vérifier si la présence existe déjà pour cette période + const presenceExistante = await db + .select() + .from(presences) + .where( + and( + eq(presences.inscriptionId, params.inscriptionId), + eq(presences.dateFormationId, params.dateFormationId), + sql`${presences.periode} = ${params.periode}` + ) + ); + + if (presenceExistante.length > 0) { + throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); + } + + // Créer la présence + await db.insert(presences).values({ + inscriptionId: params.inscriptionId, + dateFormationId: params.dateFormationId, + periode: params.periode, + heurePresence: new Date(), + modeValidation: params.modeValidation, + validateurId: params.validateurId, + commentaire: params.commentaire, + signatureUrl: params.signatureUrl, + signatureS3Key: params.signatureS3Key, + }); + + return { success: true }; +} + +/** + * Récupérer toutes les présences pour une séquence + */ +export async function getPresencesBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db + .select({ + presenceId: presences.id, + inscriptionId: presences.inscriptionId, + dateFormationId: presences.dateFormationId, + periode: presences.periode, + heurePresence: presences.heurePresence, + modeValidation: presences.modeValidation, + validateurId: presences.validateurId, + commentaire: presences.commentaire, + signatureUrl: sql`${presences}.signatureUrl`, + signatureS3Key: sql`${presences}.signatureS3Key`, + apprenantId: apprenants.id, + apprenantNom: apprenants.nom, + apprenantPrenom: apprenants.prenom, + apprenantEmail: apprenants.email, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + }) + .from(presences) + .innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id)) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) + .where(eq(datesFormation.sequenceId, sequenceId)); + + return result; +} + +/** + * Récupérer les présences pour une inscription spécifique + */ +export async function getPresencesByInscription(inscriptionId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db + .select({ + presenceId: presences.id, + dateFormationId: presences.dateFormationId, + periode: presences.periode, + heurePresence: presences.heurePresence, + modeValidation: presences.modeValidation, + validateurId: presences.validateurId, + commentaire: presences.commentaire, + signatureUrl: sql`${presences}.signatureUrl`, + signatureS3Key: sql`${presences}.signatureS3Key`, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + }) + .from(presences) + .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) + .where(eq(presences.inscriptionId, inscriptionId)); + + return result; +} + +/** + * Vérifier si toutes les présences sont validées pour une inscription + */ +export async function checkAllPresencesValidated(inscriptionId: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Récupérer l'inscription avec la séquence + const inscription = await db + .select({ + sequenceId: inscriptions.sequenceId, + }) + .from(inscriptions) + .where(eq(inscriptions.id, inscriptionId)) + .limit(1); + + if (inscription.length === 0) { + throw new Error("Inscription not found"); + } + + // Compter le nombre de dates de formation pour cette séquence + const datesCount = await db + .select({ count: datesFormation.id }) + .from(datesFormation) + .where(eq(datesFormation.sequenceId, inscription[0].sequenceId)); + + // Compter le nombre de présences validées pour cette inscription + const presencesCount = await db + .select({ count: presences.id }) + .from(presences) + .where(eq(presences.inscriptionId, inscriptionId)); + + return presencesCount.length === datesCount.length; +} + +/** + * Supprimer une présence + */ +export async function supprimerPresence(presenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(presences).where(eq(presences.id, presenceId)); + + return { success: true }; +} + +/** + * Valider la présence d'un formateur (par QR code ou manuellement) + */ +export async function validerPresenceFormateur(params: { + formateurId: number; + sequenceId: number; + dateFormationId?: number; + periode: "matin" | "apres_midi"; + modeValidation: "qrcode" | "manuel"; + commentaire?: string; + signatureUrl?: string; + signatureS3Key?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Récupérer la date de formation + const dateFormation = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.id, params.dateFormationId)) + .limit(1); + + if (dateFormation.length === 0) { + throw new Error("Date de formation introuvable"); + } + + // Vérifier que la date actuelle est le jour de la formation ou après + const now = new Date(); + const dateDebut = new Date(dateFormation[0].dateDebut); + + // Réinitialiser les heures pour comparer uniquement les dates + now.setHours(0, 0, 0, 0); + dateDebut.setHours(0, 0, 0, 0); + + if (now < dateDebut) { + throw new Error("Vous ne pouvez pas émarger avant la date de formation"); + } + + // Vérifier si la présence existe déjà pour cette période + const presenceExistante = await db + .select() + .from(presences) + .where( + and( + eq(presences.formateurId, params.formateurId), + eq(presences.dateFormationId, params.dateFormationId), + sql`${presences.periode} = ${params.periode}`, + sql`${presences.typeUtilisateur} = 'formateur'` + ) + ); + + if (presenceExistante.length > 0) { + throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); + } + + // Créer la présence formateur + await db.insert(presences).values({ + typeUtilisateur: "formateur", + formateurId: params.formateurId, + dateFormationId: params.dateFormationId, + periode: params.periode, + heurePresence: new Date(), + modeValidation: params.modeValidation, + commentaire: params.commentaire, + signatureUrl: params.signatureUrl, + signatureS3Key: params.signatureS3Key, + }); + + return { success: true }; +} + +/** + * Récupérer les présences du formateur pour une séquence + */ +export async function getPresencesFormateurBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db + .select({ + presenceId: presences.id, + formateurId: presences.formateurId, + dateFormationId: presences.dateFormationId, + periode: presences.periode, + heurePresence: presences.heurePresence, + modeValidation: presences.modeValidation, + commentaire: presences.commentaire, + signatureUrl: sql`${presences}.signatureUrl`, + signatureS3Key: sql`${presences}.signatureS3Key`, + }) + .from(presences) + .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) + .where( + and( + eq(datesFormation.sequenceId, sequenceId), + sql`${presences}.typeUtilisateur = 'formateur'` + ) + ); + + return result; +} diff --git a/server/qrCodeGenerator.ts b/server/qrCodeGenerator.ts index 0d081a3..57f60a4 100644 --- a/server/qrCodeGenerator.ts +++ b/server/qrCodeGenerator.ts @@ -38,6 +38,35 @@ export async function generateQRCodeDataURL(token: string): Promise { } } +/** + * Générer un QR code formateur en base64 à partir d'un token + * @param token Le token unique de la séquence + * @returns Une image QR code en base64 (data URL) pour l'émargement formateur + */ +export async function generateQRCodeFormateurDataURL(token: string): Promise { + // URL complète pour scanner le QR code formateur + const parametres = await getParametres(); + const url = `${parametres.urlPublique}/emargement-formateur/${token}`; + + try { + const qrCodeDataURL = await QRCode.toDataURL(url, { + errorCorrectionLevel: "H", + type: "image/png", + width: 400, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + + return qrCodeDataURL; + } catch (error) { + console.error("Erreur lors de la génération du QR code formateur:", error); + throw new Error("Impossible de générer le QR code formateur"); + } +} + /** * Générer un QR code en buffer PNG * @param token Le token unique de la séquence diff --git a/server/routers.ts b/server/routers.ts index b111abd..11fd55e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -2517,6 +2517,42 @@ export const appRouter = router({ }; }), + // Générer un QR code formateur pour une séquence + generateQRCodeFormateur: protectedProcedure + .input(z.object({ + sequenceId: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { generateQRToken, generateQRCodeFormateurDataURL } = await import("./qrCodeGenerator"); + const { getDb } = await import("./db"); + const { sequences } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier que la séquence existe + const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1); + if (sequence.length === 0) { + throw new Error("Séquence introuvable"); + } + + // Générer un nouveau token si nécessaire + let token = sequence[0].qrCodeToken; + if (!token) { + token = generateQRToken(); + await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId)); + } + + // Générer le QR code formateur + const qrCodeDataURL = await generateQRCodeFormateurDataURL(token); + + return { + token, + qrCodeDataURL, + }; + }), + // Récupérer les informations de la séquence par token (pour formateurs) getSequenceByToken: publicProcedure .input(z.object({ @@ -2547,15 +2583,86 @@ export const appRouter = router({ }; }), + // Valider la présence d'un formateur (QR code dédié) + validerFormateur: publicProcedure + .input(z.object({ + token: z.string(), + email: z.string().email(), + periode: z.enum(["matin", "apres_midi"]), + signatureDataUrl: z.string(), + })) + .mutation(async ({ input }) => { + const { getDb } = await import("./db"); + const { formateurs, sequences, datesFormation } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + const { validerPresenceFormateur } = await import("./presenceDb"); + const { storagePut } = await import("./storage"); + const crypto = await import("crypto"); + const path = await import("path"); + const fs = await import("fs/promises"); + + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier que l'email correspond à un formateur + const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1); + if (formateurData.length === 0) { + throw new Error("Aucun formateur trouvé avec cet email"); + } + + // Vérifier le token et récupérer la séquence + const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); + if (sequence.length === 0) { + throw new Error("QR code invalide"); + } + + // Récupérer la première date de la séquence + const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequence[0].id)).orderBy(datesFormation.dateDebut).limit(1); + if (dates.length === 0) { + throw new Error("Aucune date de formation trouvée pour cette séquence"); + } + + // Stocker la signature localement + const formationId = sequence[0].formationId; + const sequenceId = sequence[0].id; + const uploadsDir = path.join(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`); + await fs.mkdir(uploadsDir, { recursive: true }); + + const randomSuffix = crypto.randomBytes(8).toString("hex"); + const fileName = `signature-formateur-${formateurData[0].id}-${dates[0].id}-${randomSuffix}.png`; + const filePath = path.join(uploadsDir, fileName); + + const base64Data = input.signatureDataUrl.split(",")[1]; + const buffer = Buffer.from(base64Data, "base64"); + await fs.writeFile(filePath, buffer); + + const signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`; + const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`; + + // Valider la présence formateur + await validerPresenceFormateur({ + formateurId: formateurData[0].id, + sequenceId: sequence[0].id, + dateFormationId: dates[0].id, + periode: input.periode, + modeValidation: "qrcode", + signatureUrl, + signatureS3Key, + }); + + return { success: true }; + }), + // Valider une présence (scan QR code ou manuel) valider: publicProcedure .input(z.object({ token: z.string().optional(), email: z.string().email(), inscriptionId: z.number().optional(), - dateFormationId: z.number(), + dateFormationId: z.number().optional(), + dateId: z.number().optional(), // Alias pour dateFormationId periode: z.enum(["matin", "apres_midi"]), - modeValidation: z.enum(["qrcode", "manuel"]), + modeValidation: z.enum(["qrcode", "manuel"]).optional().default("qrcode"), validateurId: z.number().optional(), commentaire: z.string().optional(), signatureDataUrl: z.string().optional(), @@ -2575,14 +2682,24 @@ export const appRouter = router({ // Si mode QR code, vérifier le token let sequenceId: number | undefined; + let autoDateFormationId: number | undefined; if (input.modeValidation === "qrcode" && input.token) { // Vérifier que le token correspond à une séquence + const { datesFormation } = await import("../drizzle/schema"); const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); if (sequence.length === 0) { throw new Error("QR code invalide"); } sequenceId = sequence[0].id; + // Si formateur, récupérer automatiquement la première date de la séquence + if (isFormateur && !input.dateFormationId && !input.dateId) { + const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)).orderBy(datesFormation.dateDebut).limit(1); + if (dates.length > 0) { + autoDateFormationId = dates[0].id; + } + } + // Si apprenant, vérifier l'inscription if (!isFormateur && input.inscriptionId) { const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); @@ -2631,7 +2748,8 @@ export const appRouter = router({ const randomSuffix = crypto.randomBytes(8).toString("hex"); const userType = isFormateur ? "formateur" : "apprenant"; const userId = isFormateur ? formateurData[0].id : input.inscriptionId; - const fileName = `signature-${userType}-${userId}-${input.dateFormationId}-${randomSuffix}.png`; + const dateRef = input.dateFormationId || input.dateId || "no-date"; + const fileName = `signature-${userType}-${userId}-${dateRef}-${randomSuffix}.png`; const filePath = path.join(uploadsDir, fileName); // Convertir le data URL en buffer et sauvegarder @@ -2651,9 +2769,9 @@ export const appRouter = router({ result = await validerPresenceFormateur({ formateurId: formateurData[0].id, sequenceId: finalSequenceId, - dateFormationId: input.dateFormationId, + dateFormationId: input.dateFormationId || input.dateId || undefined, periode: input.periode, - modeValidation: input.modeValidation, + modeValidation: input.modeValidation || "qrcode", commentaire: input.commentaire, signatureUrl, signatureS3Key, @@ -2661,11 +2779,13 @@ export const appRouter = router({ } else { // Valider la présence de l'apprenant if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant"); + const dateFormId = input.dateFormationId || input.dateId; + if (!dateFormId) throw new Error("Date de formation requise pour un apprenant"); result = await validerPresence({ inscriptionId: input.inscriptionId, - dateFormationId: input.dateFormationId, + dateFormationId: dateFormId, periode: input.periode, - modeValidation: input.modeValidation, + modeValidation: input.modeValidation || "qrcode", validateurId: input.validateurId, commentaire: input.commentaire, signatureUrl, diff --git a/todo.md b/todo.md index bb10c16..3ad8eea 100644 --- a/todo.md +++ b/todo.md @@ -1523,3 +1523,34 @@ - [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant) - [ ] Tester sur smartphone - [x] Déployer la correction sur le VPS + +## Bug : Chargement infini en mode formateur (émargement QR code) + +- [x] Diagnostiquer pourquoi getSequenceByToken ne retourne pas de données +- [x] Vérifier que le token QR code est bien passé dans l'URL +- [x] Corriger le problème de récupération des données de séquence +- [ ] Tester sur smartphone +- [x] Déployer la correction sur le VPS (redémarrage PM2 complet effectué) + +## Correction finale : Simplifier l'approche sans getSequenceByToken + +- [x] Retirer getSequenceByToken et utiliser la logique existante de validation +- [x] Modifier EmargementScan.tsx pour détecter le formateur sans appel API supplémentaire +- [ ] Vider tous les caches (Nginx, navigateur) +- [ ] Tester le flux complet +- [x] Déployer sur le VPS + +## Bug : Erreur de validation backend pour les formateurs + +- [ ] Modifier le schéma de validation de `presences.valider` pour rendre `inscriptionId` et `dateFormationId` optionnels +- [ ] Adapter la logique backend pour gérer les formateurs sans ces champs +- [ ] Déployer la correction sur le VPS + +## Solution finale : QR code dédié pour les formateurs + +- [x] Créer la page `/emargement-formateur/:token` avec formulaire simplifié (email + période + signature) +- [x] Créer la procédure backend `presences.validerFormateur` dédiée +- [x] Ajouter un bouton "QR Code Formateur" dans FormateurEmargement.tsx +- [x] Créer la procédure `generateQRCodeFormateur` pour générer l'URL formateur +- [ ] Tester le flux complet +- [x] Déployer sur le VPS