Checkpoint: Implémentation du QR code dédié pour l'émargement formateur avec signature tactile
- Création de la page /emargement-formateur/:token avec formulaire simplifié - Procédure backend presences.validerFormateur dédiée - Deux boutons QR code dans l'interface formateur (apprenants + formateur) - Génération automatique des URL distinctes pour chaque type d'utilisateur - Déploiement réussi sur le VPS de production
This commit is contained in:
@@ -5,6 +5,7 @@ import { Route, Switch } from "wouter";
|
|||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
import EmargementFormateurScan from "./pages/EmargementFormateurScan";
|
||||||
import LoginChoice from "./pages/LoginChoice";
|
import LoginChoice from "./pages/LoginChoice";
|
||||||
import AdminRapportPublicCible from "./pages/AdminRapportPublicCible";
|
import AdminRapportPublicCible from "./pages/AdminRapportPublicCible";
|
||||||
import Admin from "./pages/Admin";
|
import Admin from "./pages/Admin";
|
||||||
@@ -48,8 +49,9 @@ function Router() {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={LoginChoice} />
|
<Route path={"/"} component={LoginChoice} />
|
||||||
<Route path={"/home"} component={Home} />
|
<Route path={"/"} component={Home} />
|
||||||
<Route path={"/login"} component={Login} />
|
<Route path={"/emargement-formateur/:token"} component={EmargementFormateurScan} />
|
||||||
|
<Route path={"/404"} component={NotFound} />
|
||||||
<Route path={"/forgot-password"} component={ForgotPassword} />
|
<Route path={"/forgot-password"} component={ForgotPassword} />
|
||||||
<Route path={"/reset-password"} component={ResetPassword} />
|
<Route path={"/reset-password"} component={ResetPassword} />
|
||||||
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />
|
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />
|
||||||
|
|||||||
220
client/src/pages/EmargementFormateurScan.tsx
Normal file
220
client/src/pages/EmargementFormateurScan.tsx
Normal file
@@ -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<Date | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<XCircle className="h-6 w-6" />
|
||||||
|
Lien invalide
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Le lien d'émargement est invalide ou a expiré.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSignaturePad) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Signature de présence formateur</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Signez pour valider votre présence en tant que formateur
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignaturePadComponent
|
||||||
|
onSave={handleSaveSignature}
|
||||||
|
onCancel={() => setShowSignaturePad(false)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||||
|
<CheckCircle2 className="h-6 w-6" />
|
||||||
|
Présence validée
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Votre présence a été enregistrée avec succès
|
||||||
|
{heureEnregistrement && (
|
||||||
|
<span className="block mt-2 text-sm">
|
||||||
|
Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-rose-100 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<XCircle className="h-6 w-6" />
|
||||||
|
Erreur
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-red-600">{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={() => window.location.reload()} className="w-full">
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Émargement formateur</CardTitle>
|
||||||
|
<CardDescription>Validez votre présence en tant que formateur</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{!emailConfirmed ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="votre.email@exemple.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearchEmail();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearchEmail} className="w-full">
|
||||||
|
Continuer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-2xl">👨🏫</span>
|
||||||
|
<span className="font-semibold text-blue-900">Mode formateur</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">{email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="periode">Période</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedPeriode}
|
||||||
|
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="matin">Matin</SelectItem>
|
||||||
|
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleValider} className="w-full" disabled={validating}>
|
||||||
|
{validating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Validation en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Signer ma présence"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,13 +38,7 @@ export default function EmargementScan() {
|
|||||||
// Récupérer les inscriptions de l'apprenant avec les dates
|
// Récupérer les inscriptions de l'apprenant avec les dates
|
||||||
const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery(
|
const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery(
|
||||||
{ apprenantId: apprenant?.id || 0 },
|
{ apprenantId: apprenant?.id || 0 },
|
||||||
{ enabled: !!apprenant }
|
{ enabled: !!apprenant && !isFormateur }
|
||||||
);
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Récupérer les dates de formation pour l'inscription sélectionnée
|
// Récupérer les dates de formation pour l'inscription sélectionnée
|
||||||
@@ -86,129 +80,111 @@ export default function EmargementScan() {
|
|||||||
setIsFormateur(false);
|
setIsFormateur(false);
|
||||||
setEmailConfirmed(true);
|
setEmailConfirmed(true);
|
||||||
} else {
|
} else {
|
||||||
// Peut-être un formateur, on continue quand même
|
// C'est un formateur
|
||||||
setIsFormateur(true);
|
setIsFormateur(true);
|
||||||
setEmailConfirmed(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 = () => {
|
const handleValider = () => {
|
||||||
if (isFormateur) {
|
if (isFormateur) {
|
||||||
// Pour un formateur, on a juste besoin de la date et de la période
|
// Pour les formateurs, on affiche le pad de signature
|
||||||
if (!selectedDateId) {
|
setShowSignaturePad(true);
|
||||||
toast.error("Veuillez sélectionner une date de formation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Pour un apprenant, on a besoin de l'inscription et de la date
|
// Pour les apprenants, vérifier les champs requis
|
||||||
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
|
|
||||||
if (!selectedDateId) {
|
if (!selectedDateId) {
|
||||||
toast.error("Veuillez sélectionner une date de formation");
|
toast.error("Veuillez sélectionner une date");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowSignaturePad(true);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Déterminer les dates disponibles
|
if (!token) {
|
||||||
const datesDisponibles = isFormateur
|
return (
|
||||||
? (sequenceData?.dates || [])
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
: ((selectedInscription?.sequence as any)?.dates || []);
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<XCircle className="h-6 w-6" />
|
||||||
|
Lien invalide
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Le lien d'émargement est invalide ou a expiré.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSignaturePad) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Signature de présence</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isFormateur
|
||||||
|
? "Signez pour valider votre présence en tant que formateur"
|
||||||
|
: "Signez pour valider votre présence"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignaturePadComponent
|
||||||
|
onSave={handleSaveSignature}
|
||||||
|
onCancel={() => setShowSignaturePad(false)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-emerald-50 to-green-100 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||||
<Card className="max-w-md w-full shadow-2xl border-2 border-green-200 animate-in fade-in zoom-in duration-500">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader>
|
||||||
<div className="mx-auto mb-4 w-20 h-20 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center shadow-lg animate-in zoom-in duration-700 delay-150">
|
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||||
<CheckCircle2 className="h-12 w-12 text-white animate-in zoom-in duration-500 delay-300" />
|
<CheckCircle2 className="h-6 w-6" />
|
||||||
</div>
|
Présence validée
|
||||||
<CardTitle className="text-3xl font-bold text-green-700 animate-in slide-in-from-bottom duration-500 delay-200">
|
|
||||||
Présence validée !
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base mt-2 animate-in slide-in-from-bottom duration-500 delay-300">
|
<CardDescription>
|
||||||
Votre présence a été enregistrée avec succès
|
Votre présence a été enregistrée avec succès
|
||||||
|
{heureEnregistrement && (
|
||||||
|
<span className="block mt-2 text-sm">
|
||||||
|
Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center space-y-4 animate-in slide-in-from-bottom duration-500 delay-400">
|
|
||||||
{heureEnregistrement && (
|
|
||||||
<div className="p-4 bg-green-50 border-2 border-green-200 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-green-800 mb-1">
|
|
||||||
🕒 Heure d'enregistrement
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-green-700">
|
|
||||||
{heureEnregistrement.toLocaleTimeString("fr-FR", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">
|
|
||||||
{heureEnregistrement.toLocaleDateString("fr-FR", {
|
|
||||||
weekday: "long",
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Merci d'avoir confirmé votre présence. Vous pouvez maintenant fermer cette page.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
setSuccess(false);
|
|
||||||
setHeureEnregistrement(null);
|
|
||||||
setEmail("");
|
|
||||||
setEmailConfirmed(false);
|
|
||||||
setSelectedInscriptionId(null);
|
|
||||||
setSelectedDateId(null);
|
|
||||||
setError(null);
|
|
||||||
setIsFormateur(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Valider une autre présence
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -216,26 +192,17 @@ export default function EmargementScan() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-red-100 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-rose-100 p-4">
|
||||||
<Card className="max-w-md w-full">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader>
|
||||||
<div className="mx-auto mb-4 w-16 h-16 bg-red-500 rounded-full flex items-center justify-center">
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
<XCircle className="h-10 w-10 text-white" />
|
<XCircle className="h-6 w-6" />
|
||||||
</div>
|
Erreur
|
||||||
<CardTitle className="text-2xl text-red-700">Erreur</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{error}</CardDescription>
|
<CardDescription className="text-red-600">{error}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center">
|
<CardContent>
|
||||||
<Button
|
<Button onClick={() => window.location.reload()} className="w-full">
|
||||||
onClick={() => {
|
|
||||||
setError(null);
|
|
||||||
setEmail("");
|
|
||||||
setEmailConfirmed(false);
|
|
||||||
setSelectedInscriptionId(null);
|
|
||||||
setSelectedDateId(null);
|
|
||||||
setIsFormateur(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Réessayer
|
Réessayer
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -246,23 +213,20 @@ export default function EmargementScan() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
<Card className="max-w-md w-full">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Émargement numérique</CardTitle>
|
<CardTitle>Émargement numérique</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Validez votre présence en renseignant vos informations</CardDescription>
|
||||||
Validez votre présence en renseignant vos informations
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-6">
|
||||||
{/* Étape 1: Saisie de l'email */}
|
{!emailConfirmed ? (
|
||||||
{!emailConfirmed && (
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Label htmlFor="email">Votre email</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="prenom.nom@exemple.fr"
|
placeholder="votre.email@exemple.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -276,210 +240,140 @@ export default function EmargementScan() {
|
|||||||
Continuer
|
Continuer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : isFormateur ? (
|
||||||
|
|
||||||
{/* Étape 2: Sélection pour apprenant */}
|
|
||||||
{emailConfirmed && !isFormateur && apprenant && !success && (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<p className="text-sm font-medium">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{apprenant.prenom} {apprenant.nom}
|
<span className="text-2xl">👨🏫</span>
|
||||||
</p>
|
<span className="font-semibold text-blue-900">Mode formateur</span>
|
||||||
<p className="text-xs text-muted-foreground">{apprenant.email}</p>
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">{email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="periode">Période</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedPeriode}
|
||||||
|
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="matin">Matin</SelectItem>
|
||||||
|
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleValider} className="w-full" disabled={validating}>
|
||||||
|
{validating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Validation en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Signer ma présence"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-green-900">{apprenant?.nom} {apprenant?.prenom}</p>
|
||||||
|
<p className="text-sm text-green-700">{email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inscriptions && inscriptions.length > 0 ? (
|
{inscriptions && inscriptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Sélectionnez votre formation</Label>
|
<Label htmlFor="inscription">Formation</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedInscriptionId?.toString() || ""}
|
value={selectedInscriptionId?.toString()}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setSelectedInscriptionId(parseInt(value));
|
setSelectedInscriptionId(parseInt(value));
|
||||||
setSelectedDateId(null);
|
setSelectedDateId(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choisir une formation..." />
|
<SelectValue placeholder="Sélectionnez une formation" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{inscriptions.map((inscr) => (
|
{inscriptions.map((item) => (
|
||||||
<SelectItem key={inscr.inscription.id} value={inscr.inscription.id.toString()}>
|
<SelectItem key={item.inscription.id} value={item.inscription.id.toString()}>
|
||||||
{inscr.sequence?.nom || "Formation"}
|
{item.sequence.formation.nom} - {item.sequence.nom}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedInscriptionId && datesDisponibles.length > 0 && (
|
{selectedInscription && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<div>
|
<Label htmlFor="date">Date</Label>
|
||||||
<Label>Sélectionnez la date</Label>
|
<Select
|
||||||
<Select
|
value={selectedDateId?.toString()}
|
||||||
value={selectedDateId?.toString() || ""}
|
onValueChange={(value) => setSelectedDateId(parseInt(value))}
|
||||||
onValueChange={(value) => setSelectedDateId(parseInt(value))}
|
>
|
||||||
>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder="Sélectionnez une date" />
|
||||||
<SelectValue placeholder="Choisir une date..." />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{selectedInscription.dates.map((date) => (
|
||||||
{datesDisponibles.map((date: any) => (
|
<SelectItem key={date.id} value={date.id.toString()}>
|
||||||
<SelectItem key={date.id} value={date.id.toString()}>
|
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
|
||||||
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
|
weekday: "long",
|
||||||
weekday: "long",
|
year: "numeric",
|
||||||
year: "numeric",
|
month: "long",
|
||||||
month: "long",
|
day: "numeric",
|
||||||
day: "numeric",
|
})}
|
||||||
})}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Période</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedPeriode}
|
|
||||||
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="matin">Matin</SelectItem>
|
|
||||||
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleValider} className="w-full" disabled={validating}>
|
|
||||||
{validating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Validation en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Signer et valider"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground py-4">
|
|
||||||
Aucune inscription trouvée pour cet email
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Étape 2: Sélection pour formateur */}
|
|
||||||
{emailConfirmed && isFormateur && !success && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-blue-900">
|
|
||||||
👨🏫 Mode formateur
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-700">{email}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sequenceData && sequenceData.dates && sequenceData.dates.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label>Formation</Label>
|
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
|
||||||
<p className="text-sm font-medium">{sequenceData.nom}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{sequenceData.lieu}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Sélectionnez la date</Label>
|
<Label htmlFor="periode">Période</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedDateId?.toString() || ""}
|
value={selectedPeriode}
|
||||||
onValueChange={(value) => setSelectedDateId(parseInt(value))}
|
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choisir une date..." />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sequenceData.dates.map((date: any) => (
|
<SelectItem value="matin">Matin</SelectItem>
|
||||||
<SelectItem key={date.id} value={date.id.toString()}>
|
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
||||||
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
|
|
||||||
weekday: "long",
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedDateId && (
|
<Button
|
||||||
<>
|
onClick={handleValider}
|
||||||
<div>
|
className="w-full"
|
||||||
<Label>Période</Label>
|
disabled={!selectedDateId || validating}
|
||||||
<Select
|
>
|
||||||
value={selectedPeriode}
|
{validating ? (
|
||||||
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
<>
|
||||||
>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<SelectTrigger>
|
Validation en cours...
|
||||||
<SelectValue />
|
</>
|
||||||
</SelectTrigger>
|
) : (
|
||||||
<SelectContent>
|
"Valider ma présence"
|
||||||
<SelectItem value="matin">Matin</SelectItem>
|
)}
|
||||||
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
</Button>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleValider} className="w-full" disabled={validating}>
|
|
||||||
{validating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Validation en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Signer et valider"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-8 text-gray-500">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
|
<p>Aucune inscription trouvée pour cet email.</p>
|
||||||
<p className="text-sm text-muted-foreground">Chargement des informations...</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Dialog de signature */}
|
|
||||||
{showSignaturePad && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
||||||
<Card className="max-w-2xl w-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Signature</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Signez dans le cadre ci-dessous pour valider votre présence
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<SignaturePadComponent
|
|
||||||
onSave={handleSaveSignature}
|
|
||||||
onCancel={() => setShowSignaturePad(false)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ export default function FormateurEmargement() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null);
|
const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null);
|
||||||
const [showQRDialog, setShowQRDialog] = useState(false);
|
const [showQRDialog, setShowQRDialog] = useState(false);
|
||||||
|
const [showQRFormateurDialog, setShowQRFormateurDialog] = useState(false);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date>(new Date());
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date>(new Date());
|
||||||
const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0);
|
const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0);
|
||||||
|
|
||||||
// Récupérer toutes les séquences (filtrées côté serveur pour les formateurs)
|
// Récupérer toutes les séquences (filtrées côté serveur pour les formateurs)
|
||||||
const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery();
|
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({
|
const { data: qrData, mutate: generateQR, isPending: generatingQR } = trpc.presences.generateQRCode.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowQRDialog(true);
|
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
|
// Récupérer les inscrits avec les dates de formation
|
||||||
const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery(
|
const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery(
|
||||||
{ sequenceId: selectedSequenceId || 0 },
|
{ sequenceId: selectedSequenceId || 0 },
|
||||||
@@ -121,6 +134,15 @@ export default function FormateurEmargement() {
|
|||||||
generateQR({ sequenceId: selectedSequenceId });
|
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") => {
|
const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => {
|
||||||
validerPresence({
|
validerPresence({
|
||||||
inscriptionId,
|
inscriptionId,
|
||||||
@@ -209,7 +231,7 @@ export default function FormateurEmargement() {
|
|||||||
|
|
||||||
{selectedSequenceId && (
|
{selectedSequenceId && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button onClick={handleGenerateQR} disabled={generatingQR}>
|
<Button onClick={handleGenerateQR} disabled={generatingQR}>
|
||||||
{generatingQR ? (
|
{generatingQR ? (
|
||||||
<>
|
<>
|
||||||
@@ -219,7 +241,20 @@ export default function FormateurEmargement() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<QrCode className="mr-2 h-4 w-4" />
|
<QrCode className="mr-2 h-4 w-4" />
|
||||||
Afficher le QR code
|
QR Code Apprenants
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleGenerateQRFormateur} disabled={generatingQRFormateur} variant="secondary">
|
||||||
|
{generatingQRFormateur ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Génération...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<QrCode className="mr-2 h-4 w-4" />
|
||||||
|
QR Code Formateur
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -451,13 +486,13 @@ export default function FormateurEmargement() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog QR Code */}
|
{/* Dialog QR Code Apprenants */}
|
||||||
<Dialog open={showQRDialog} onOpenChange={setShowQRDialog}>
|
<Dialog open={showQRDialog} onOpenChange={setShowQRDialog}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>QR Code d'émargement</DialogTitle>
|
<DialogTitle>QR Code d'émargement apprenants</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
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
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{qrData && (
|
{qrData && (
|
||||||
@@ -475,6 +510,38 @@ export default function FormateurEmargement() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog QR Code Formateur */}
|
||||||
|
<Dialog open={showQRFormateurDialog} onOpenChange={setShowQRFormateurDialog}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>QR Code d'émargement formateur</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Scannez ce QR code avec votre smartphone pour émarger en tant que formateur avec signature tactile
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{qrFormateurData && (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
<img
|
||||||
|
src={qrFormateurData.qrCodeDataURL}
|
||||||
|
alt="QR Code Formateur"
|
||||||
|
className="w-full max-w-[300px] border rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 w-full">
|
||||||
|
<p className="text-sm font-semibold text-blue-900 mb-2">👨🏫 Mode formateur
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Affichez ce QR code en plein écran pour faciliter le scan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Dialog Signature */}
|
{/* Dialog Signature */}
|
||||||
<Dialog open={showSignatureDialog} onOpenChange={setShowSignatureDialog}>
|
<Dialog open={showSignatureDialog} onOpenChange={setShowSignatureDialog}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
|
|||||||
BIN
formation-manager-update-20260202-150825.tar.gz
Normal file
BIN
formation-manager-update-20260202-150825.tar.gz
Normal file
Binary file not shown.
72
server/presenceDb-formateur-fix.ts
Normal file
72
server/presenceDb-formateur-fix.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -189,7 +189,7 @@ export async function supprimerPresence(presenceId: number) {
|
|||||||
export async function validerPresenceFormateur(params: {
|
export async function validerPresenceFormateur(params: {
|
||||||
formateurId: number;
|
formateurId: number;
|
||||||
sequenceId: number;
|
sequenceId: number;
|
||||||
dateFormationId: number;
|
dateFormationId?: number;
|
||||||
periode: "matin" | "apres_midi";
|
periode: "matin" | "apres_midi";
|
||||||
modeValidation: "qrcode" | "manuel";
|
modeValidation: "qrcode" | "manuel";
|
||||||
commentaire?: string;
|
commentaire?: string;
|
||||||
|
|||||||
287
server/presenceDb.ts.backup
Normal file
287
server/presenceDb.ts.backup
Normal file
@@ -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<string | null>`${presences}.signatureUrl`,
|
||||||
|
signatureS3Key: sql<string | null>`${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<string | null>`${presences}.signatureUrl`,
|
||||||
|
signatureS3Key: sql<string | null>`${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<boolean> {
|
||||||
|
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<string | null>`${presences}.signatureUrl`,
|
||||||
|
signatureS3Key: sql<string | null>`${presences}.signatureS3Key`,
|
||||||
|
})
|
||||||
|
.from(presences)
|
||||||
|
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(datesFormation.sequenceId, sequenceId),
|
||||||
|
sql`${presences}.typeUtilisateur = 'formateur'`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -38,6 +38,35 @@ export async function generateQRCodeDataURL(token: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> {
|
||||||
|
// 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
|
* Générer un QR code en buffer PNG
|
||||||
* @param token Le token unique de la séquence
|
* @param token Le token unique de la séquence
|
||||||
|
|||||||
@@ -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)
|
// Récupérer les informations de la séquence par token (pour formateurs)
|
||||||
getSequenceByToken: publicProcedure
|
getSequenceByToken: publicProcedure
|
||||||
.input(z.object({
|
.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 une présence (scan QR code ou manuel)
|
||||||
valider: publicProcedure
|
valider: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
inscriptionId: z.number().optional(),
|
inscriptionId: z.number().optional(),
|
||||||
dateFormationId: z.number(),
|
dateFormationId: z.number().optional(),
|
||||||
|
dateId: z.number().optional(), // Alias pour dateFormationId
|
||||||
periode: z.enum(["matin", "apres_midi"]),
|
periode: z.enum(["matin", "apres_midi"]),
|
||||||
modeValidation: z.enum(["qrcode", "manuel"]),
|
modeValidation: z.enum(["qrcode", "manuel"]).optional().default("qrcode"),
|
||||||
validateurId: z.number().optional(),
|
validateurId: z.number().optional(),
|
||||||
commentaire: z.string().optional(),
|
commentaire: z.string().optional(),
|
||||||
signatureDataUrl: z.string().optional(),
|
signatureDataUrl: z.string().optional(),
|
||||||
@@ -2575,14 +2682,24 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Si mode QR code, vérifier le token
|
// Si mode QR code, vérifier le token
|
||||||
let sequenceId: number | undefined;
|
let sequenceId: number | undefined;
|
||||||
|
let autoDateFormationId: number | undefined;
|
||||||
if (input.modeValidation === "qrcode" && input.token) {
|
if (input.modeValidation === "qrcode" && input.token) {
|
||||||
// Vérifier que le token correspond à une séquence
|
// 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);
|
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1);
|
||||||
if (sequence.length === 0) {
|
if (sequence.length === 0) {
|
||||||
throw new Error("QR code invalide");
|
throw new Error("QR code invalide");
|
||||||
}
|
}
|
||||||
sequenceId = sequence[0].id;
|
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
|
// Si apprenant, vérifier l'inscription
|
||||||
if (!isFormateur && input.inscriptionId) {
|
if (!isFormateur && input.inscriptionId) {
|
||||||
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
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 randomSuffix = crypto.randomBytes(8).toString("hex");
|
||||||
const userType = isFormateur ? "formateur" : "apprenant";
|
const userType = isFormateur ? "formateur" : "apprenant";
|
||||||
const userId = isFormateur ? formateurData[0].id : input.inscriptionId;
|
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);
|
const filePath = path.join(uploadsDir, fileName);
|
||||||
|
|
||||||
// Convertir le data URL en buffer et sauvegarder
|
// Convertir le data URL en buffer et sauvegarder
|
||||||
@@ -2651,9 +2769,9 @@ export const appRouter = router({
|
|||||||
result = await validerPresenceFormateur({
|
result = await validerPresenceFormateur({
|
||||||
formateurId: formateurData[0].id,
|
formateurId: formateurData[0].id,
|
||||||
sequenceId: finalSequenceId,
|
sequenceId: finalSequenceId,
|
||||||
dateFormationId: input.dateFormationId,
|
dateFormationId: input.dateFormationId || input.dateId || undefined,
|
||||||
periode: input.periode,
|
periode: input.periode,
|
||||||
modeValidation: input.modeValidation,
|
modeValidation: input.modeValidation || "qrcode",
|
||||||
commentaire: input.commentaire,
|
commentaire: input.commentaire,
|
||||||
signatureUrl,
|
signatureUrl,
|
||||||
signatureS3Key,
|
signatureS3Key,
|
||||||
@@ -2661,11 +2779,13 @@ export const appRouter = router({
|
|||||||
} else {
|
} else {
|
||||||
// Valider la présence de l'apprenant
|
// Valider la présence de l'apprenant
|
||||||
if (!input.inscriptionId) throw new Error("Inscription requise pour un 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({
|
result = await validerPresence({
|
||||||
inscriptionId: input.inscriptionId,
|
inscriptionId: input.inscriptionId,
|
||||||
dateFormationId: input.dateFormationId,
|
dateFormationId: dateFormId,
|
||||||
periode: input.periode,
|
periode: input.periode,
|
||||||
modeValidation: input.modeValidation,
|
modeValidation: input.modeValidation || "qrcode",
|
||||||
validateurId: input.validateurId,
|
validateurId: input.validateurId,
|
||||||
commentaire: input.commentaire,
|
commentaire: input.commentaire,
|
||||||
signatureUrl,
|
signatureUrl,
|
||||||
|
|||||||
31
todo.md
31
todo.md
@@ -1523,3 +1523,34 @@
|
|||||||
- [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant)
|
- [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant)
|
||||||
- [ ] Tester sur smartphone
|
- [ ] Tester sur smartphone
|
||||||
- [x] Déployer la correction sur le VPS
|
- [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
|
||||||
|
|||||||
Reference in New Issue
Block a user