Checkpoint: Implémentation complète de la fonctionnalité QR code formateur :
- Procédure generateQRCodeFormateur dans routers.ts
- Fonction validerPresenceFormateur dans presenceDb.ts
- Bouton "QR Code Formateur" dans FormateurEmargement.tsx
- Page EmargementFormateurScan.tsx pour le scan du QR code
- Route /emargement-formateur/{token}
- Gestion automatique de la date du jour
- Stockage de la signature sur S3
- Champ qrCodeFormateurToken dans le schéma sequences
Prêt pour déploiement et tests sur le VPS.
This commit is contained in:
@@ -40,6 +40,7 @@ import Login from "./pages/Login";
|
|||||||
import FormateurDashboard from "./pages/FormateurDashboard";
|
import FormateurDashboard from "./pages/FormateurDashboard";
|
||||||
import FormateurEmargement from "./pages/FormateurEmargement";
|
import FormateurEmargement from "./pages/FormateurEmargement";
|
||||||
import EmargementScan from "./pages/EmargementScan";
|
import EmargementScan from "./pages/EmargementScan";
|
||||||
|
import EmargementFormateurScan from "./pages/EmargementFormateurScan";
|
||||||
import SignerAttestation from "./pages/SignerAttestation";
|
import SignerAttestation from "./pages/SignerAttestation";
|
||||||
import ForgotPassword from "./pages/ForgotPassword";
|
import ForgotPassword from "./pages/ForgotPassword";
|
||||||
import ResetPassword from "./pages/ResetPassword";
|
import ResetPassword from "./pages/ResetPassword";
|
||||||
@@ -85,6 +86,7 @@ function Router() {
|
|||||||
<Route path={"/formateur"} component={FormateurDashboard} />
|
<Route path={"/formateur"} component={FormateurDashboard} />
|
||||||
<Route path={"/formateur/emargement"} component={FormateurEmargement} />
|
<Route path={"/formateur/emargement"} component={FormateurEmargement} />
|
||||||
<Route path={"/emargement/:token"} component={EmargementScan} />
|
<Route path={"/emargement/:token"} component={EmargementScan} />
|
||||||
|
<Route path={"/emargement-formateur/:token"} component={EmargementFormateurScan} />
|
||||||
<Route path={"/signer-attestation/:id"} component={SignerAttestation} />
|
<Route path={"/signer-attestation/:id"} component={SignerAttestation} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
|
|||||||
258
client/src/pages/EmargementFormateurScan.tsx
Normal file
258
client/src/pages/EmargementFormateurScan.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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, PenTool } 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 [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);
|
||||||
|
const [signatureDataUrl, setSignatureDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Valider la présence formateur
|
||||||
|
const { mutate: validerPresence, isPending: validating } = trpc.presences.valider.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
const now = new Date();
|
||||||
|
setHeureEnregistrement(now);
|
||||||
|
setSuccess(true);
|
||||||
|
toast.success("Présence formateur validée avec succès !");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setError(error.message);
|
||||||
|
toast.error("Erreur lors de la validation", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveSignature = (dataUrl: string) => {
|
||||||
|
setSignatureDataUrl(dataUrl);
|
||||||
|
setShowSignaturePad(false);
|
||||||
|
// Valider automatiquement après la signature
|
||||||
|
handleValiderWithSignature(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValider = () => {
|
||||||
|
if (!email) {
|
||||||
|
toast.error("Veuillez saisir votre email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher le pad de signature
|
||||||
|
setShowSignaturePad(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValiderWithSignature = (dataUrl: string) => {
|
||||||
|
if (!email) {
|
||||||
|
toast.error("Veuillez saisir votre email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validerPresence({
|
||||||
|
token: token || "",
|
||||||
|
email,
|
||||||
|
periode: selectedPeriode,
|
||||||
|
modeValidation: "qrcode",
|
||||||
|
signatureDataUrl: dataUrl,
|
||||||
|
typeUtilisateur: "formateur",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-blue-100 p-4">
|
||||||
|
<Card className="max-w-md w-full shadow-2xl border-2 border-blue-200 animate-in fade-in zoom-in duration-500">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-20 h-20 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center shadow-lg animate-in zoom-in duration-700 delay-150">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-white animate-in zoom-in duration-500 delay-300" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold text-blue-700 animate-in slide-in-from-bottom duration-500 delay-200">
|
||||||
|
Présence formateur validée !
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base mt-2 animate-in slide-in-from-bottom duration-500 delay-300">
|
||||||
|
Votre présence en tant que formateur a été enregistrée avec succès
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4 animate-in slide-in-from-bottom duration-500 delay-400">
|
||||||
|
{heureEnregistrement && (
|
||||||
|
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-blue-800 mb-1">
|
||||||
|
🕒 Heure d'enregistrement
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">
|
||||||
|
{heureEnregistrement.toLocaleTimeString("fr-FR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-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);
|
||||||
|
setEmail("");
|
||||||
|
setSignatureDataUrl(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Valider une autre présence
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 via-rose-50 to-red-100 p-4">
|
||||||
|
<Card className="max-w-md w-full shadow-2xl border-2 border-red-200">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-20 h-20 bg-gradient-to-br from-red-500 to-rose-600 rounded-full flex items-center justify-center shadow-lg">
|
||||||
|
<XCircle className="h-12 w-12 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold text-red-700">
|
||||||
|
Erreur
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base mt-2">
|
||||||
|
{error}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setEmail("");
|
||||||
|
setSignatureDataUrl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSignaturePad) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 via-violet-50 to-purple-100 p-4">
|
||||||
|
<Card className="max-w-2xl w-full shadow-2xl">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-16 h-16 bg-gradient-to-br from-purple-500 to-violet-600 rounded-full flex items-center justify-center shadow-lg">
|
||||||
|
<PenTool className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold text-purple-700">
|
||||||
|
Signature formateur
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base mt-2">
|
||||||
|
Signez avec votre doigt ou votre souris pour valider votre présence
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignaturePadComponent
|
||||||
|
onSave={handleSaveSignature}
|
||||||
|
onCancel={() => setShowSignaturePad(false)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-gray-50 to-slate-100 p-4">
|
||||||
|
<Card className="max-w-md w-full shadow-2xl">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl font-bold">
|
||||||
|
Émargement Formateur
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base mt-2">
|
||||||
|
Validez votre présence en tant que formateur
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email du formateur</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="formateur@exemple.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={validating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="periode">Période</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedPeriode}
|
||||||
|
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
|
||||||
|
disabled={validating}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="matin">Matin</SelectItem>
|
||||||
|
<SelectItem value="apres_midi">Après-midi</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleValider}
|
||||||
|
disabled={validating || !email}
|
||||||
|
>
|
||||||
|
{validating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Validation en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PenTool className="mr-2 h-4 w-4" />
|
||||||
|
Signer et valider
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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,14 @@ export default function FormateurEmargement() {
|
|||||||
generateQR({ sequenceId: selectedSequenceId });
|
generateQR({ sequenceId: selectedSequenceId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateQRFormateur = () => {
|
||||||
|
if (!selectedSequenceId) {
|
||||||
|
toast.error("Veuillez sélectionner une séquence");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 +230,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 +240,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,11 +485,11 @@ 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 doivent scanner ce QR code pour valider leur présence
|
Les apprenants doivent scanner ce QR code pour valider leur présence
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -475,6 +509,30 @@ 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 pour valider votre présence en tant que formateur
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Mode formateur : redirige vers la page d'émargement dédiée
|
||||||
|
</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">
|
||||||
|
|||||||
2298
drizzle/meta/0017_snapshot.json
Normal file
2298
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1770047974825,
|
"when": 1770047974825,
|
||||||
"tag": "0016_aromatic_king_cobra",
|
"tag": "0016_aromatic_king_cobra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1770134309991,
|
||||||
|
"tag": "0017_slimy_azazel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -90,8 +90,10 @@ export const sequences = mysqlTable("sequences", {
|
|||||||
formateurId: int("formateurId"),
|
formateurId: int("formateurId"),
|
||||||
/** Date limite d'inscription (blocage) */
|
/** Date limite d'inscription (blocage) */
|
||||||
dateBlocage: datetime("dateBlocage"),
|
dateBlocage: datetime("dateBlocage"),
|
||||||
/** Token unique pour le QR code d'émargement */
|
/** Token unique pour le QR code d'émargement apprenants */
|
||||||
qrCodeToken: varchar("qrCodeToken", { length: 100 }).unique(),
|
qrCodeToken: varchar("qrCodeToken", { length: 100 }).unique(),
|
||||||
|
/** Token unique pour le QR code d'émargement formateur */
|
||||||
|
qrCodeFormateurToken: varchar("qrCodeFormateurToken", { length: 100 }).unique(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,3 +185,76 @@ export async function supprimerPresence(presenceId: number) {
|
|||||||
|
|
||||||
return { success: true };
|
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),
|
||||||
|
eq(presences.periode, params.periode),
|
||||||
|
eq(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({
|
||||||
|
formateurId: params.formateurId,
|
||||||
|
dateFormationId: params.dateFormationId,
|
||||||
|
periode: params.periode,
|
||||||
|
typeUtilisateur: 'formateur',
|
||||||
|
heurePresence: new Date(),
|
||||||
|
modeValidation: params.modeValidation,
|
||||||
|
commentaire: params.commentaire,
|
||||||
|
signatureUrl: params.signatureUrl,
|
||||||
|
signatureS3Key: params.signatureS3Key,
|
||||||
|
dateSigned: params.signatureUrl ? new Date() : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2509,17 +2509,69 @@ export const appRouter = router({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Générer un QR code pour le formateur
|
||||||
|
generateQRCodeFormateur: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
sequenceId: z.number(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { generateQRToken } = await import("./qrCodeGenerator");
|
||||||
|
const { getDb } = await import("./db");
|
||||||
|
const { sequences } = await import("../drizzle/schema");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const QRCode = (await import("qrcode")).default;
|
||||||
|
const { getParametres } = await import("./parametresDb");
|
||||||
|
|
||||||
|
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 pour le formateur
|
||||||
|
let token = sequence[0].qrCodeFormateurToken;
|
||||||
|
if (!token) {
|
||||||
|
token = generateQRToken();
|
||||||
|
await db.update(sequences).set({ qrCodeFormateurToken: token }).where(eq(sequences.id, input.sequenceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le QR code avec URL spécifique formateur
|
||||||
|
const parametres = await getParametres();
|
||||||
|
const url = `${parametres.urlPublique}/emargement-formateur/${token}`;
|
||||||
|
|
||||||
|
const qrCodeDataURL = await QRCode.toDataURL(url, {
|
||||||
|
errorCorrectionLevel: "H",
|
||||||
|
type: "image/png",
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: "#000000",
|
||||||
|
light: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
qrCodeDataURL,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// 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(),
|
||||||
inscriptionId: z.number(),
|
inscriptionId: z.number().optional(),
|
||||||
dateFormationId: z.number(),
|
dateFormationId: z.number().optional(),
|
||||||
periode: z.enum(["matin", "apres_midi"]),
|
periode: z.enum(["matin", "apres_midi"]),
|
||||||
modeValidation: z.enum(["qrcode", "manuel"]),
|
modeValidation: z.enum(["qrcode", "manuel"]),
|
||||||
validateurId: z.number().optional(),
|
validateurId: z.number().optional(),
|
||||||
commentaire: z.string().optional(),
|
commentaire: z.string().optional(),
|
||||||
signatureDataUrl: z.string().optional(),
|
signatureDataUrl: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
typeUtilisateur: z.enum(["apprenant", "formateur"]).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { validerPresence } = await import("./presenceDb");
|
const { validerPresence } = await import("./presenceDb");
|
||||||
@@ -2530,10 +2582,91 @@ export const appRouter = router({
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
|
// Cas formateur : logique spécifique
|
||||||
|
if (input.typeUtilisateur === "formateur") {
|
||||||
|
const { formateurs, datesFormation } = await import("../drizzle/schema");
|
||||||
|
const { and, sql } = await import("drizzle-orm");
|
||||||
|
const { storagePut } = await import("./storage");
|
||||||
|
const crypto = await import("crypto");
|
||||||
|
|
||||||
|
// Vérifier le token formateur
|
||||||
|
if (input.modeValidation === "qrcode" && input.token) {
|
||||||
|
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token)).limit(1);
|
||||||
|
if (sequence.length === 0) {
|
||||||
|
throw new Error("QR code formateur invalide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le formateur par email
|
||||||
|
if (!input.email) throw new Error("Email formateur requis");
|
||||||
|
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
|
||||||
|
if (formateurData.length === 0) {
|
||||||
|
throw new Error("Formateur introuvable avec cet email");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trouver la séquence associée au token
|
||||||
|
const sequenceData = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token || "")).limit(1);
|
||||||
|
if (sequenceData.length === 0) {
|
||||||
|
throw new Error("Séquence introuvable");
|
||||||
|
}
|
||||||
|
const finalSequenceId = sequenceData[0].id;
|
||||||
|
|
||||||
|
// Trouver automatiquement la date du jour
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const datesToday = await db
|
||||||
|
.select()
|
||||||
|
.from(datesFormation)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(datesFormation.sequenceId, finalSequenceId),
|
||||||
|
sql`DATE(${datesFormation.dateDebut}) <= CURDATE()`,
|
||||||
|
sql`DATE(${datesFormation.dateFin}) >= CURDATE()`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (datesToday.length === 0) {
|
||||||
|
throw new Error("Aucune date de formation ne correspond à aujourd'hui");
|
||||||
|
}
|
||||||
|
const dateFormId = datesToday[0].id;
|
||||||
|
|
||||||
|
// Stocker la signature sur S3
|
||||||
|
let signatureUrl: string | undefined;
|
||||||
|
let signatureS3Key: string | undefined;
|
||||||
|
if (input.signatureDataUrl) {
|
||||||
|
const formationId = sequenceData[0].formationId;
|
||||||
|
const sequenceId = sequenceData[0].id;
|
||||||
|
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
||||||
|
const fileName = `signature-formateur-${formateurData[0].id}-${dateFormId}-${randomSuffix}.png`;
|
||||||
|
const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
|
||||||
|
const base64Data = input.signatureDataUrl.split(",")[1];
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
const { url } = await storagePut(signatureS3Key, buffer, "image/png");
|
||||||
|
signatureUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valider la présence formateur
|
||||||
|
const { validerPresenceFormateur } = await import("./presenceDb");
|
||||||
|
const result = await validerPresenceFormateur({
|
||||||
|
formateurId: formateurData[0].id,
|
||||||
|
sequenceId: finalSequenceId,
|
||||||
|
dateFormationId: dateFormId,
|
||||||
|
periode: input.periode,
|
||||||
|
modeValidation: input.modeValidation,
|
||||||
|
commentaire: input.commentaire,
|
||||||
|
signatureUrl,
|
||||||
|
signatureS3Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, presence: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas apprenant : logique existante
|
||||||
// Si mode QR code, vérifier le token
|
// Si mode QR code, vérifier le token
|
||||||
if (input.modeValidation === "qrcode" && input.token) {
|
if (input.modeValidation === "qrcode" && input.token) {
|
||||||
|
|
||||||
// Récupérer l'inscription pour vérifier la séquence
|
// Récupérer l'inscription pour vérifier la séquence
|
||||||
|
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
|
||||||
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);
|
||||||
if (inscription.length === 0) {
|
if (inscription.length === 0) {
|
||||||
throw new Error("Inscription introuvable");
|
throw new Error("Inscription introuvable");
|
||||||
@@ -2546,6 +2679,10 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier que inscriptionId et dateFormationId sont fournis pour un apprenant
|
||||||
|
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
|
||||||
|
if (!input.dateFormationId) throw new Error("Date de formation requise pour un apprenant");
|
||||||
|
|
||||||
// Stocker la signature localement si fournie
|
// Stocker la signature localement si fournie
|
||||||
let signatureUrl: string | undefined;
|
let signatureUrl: string | undefined;
|
||||||
let signatureS3Key: string | undefined;
|
let signatureS3Key: string | undefined;
|
||||||
|
|||||||
25
todo.md
25
todo.md
@@ -1502,3 +1502,28 @@
|
|||||||
- [x] Empêcher l'émargement avant la date de formation concernée (vérification backend)
|
- [x] Empêcher l'émargement avant la date de formation concernée (vérification backend)
|
||||||
|
|
||||||
- [ ] Permettre aux formateurs d'émarger par QR code (matin et après-midi) avec signature tactile (en cours - à finaliser lors d'une prochaine session)
|
- [ ] Permettre aux formateurs d'émarger par QR code (matin et après-midi) avec signature tactile (en cours - à finaliser lors d'une prochaine session)
|
||||||
|
|
||||||
|
## QR Code Formateur (Réimplémentation méthodique)
|
||||||
|
|
||||||
|
### Phase 1 : Analyse
|
||||||
|
- [x] Analyser le code existant pour le QR code apprenant
|
||||||
|
- [x] Identifier les modifications nécessaires pour le formateur
|
||||||
|
- [x] Planifier l'architecture de la solution
|
||||||
|
|
||||||
|
### Phase 2 : Implémentation
|
||||||
|
- [ ] Ajouter le bouton "QR Code Formateur" dans FormateurEmargement.tsx
|
||||||
|
- [ ] Créer/modifier la procédure backend generateQRCodeFormateur
|
||||||
|
- [ ] Implémenter la logique de validation de présence formateur
|
||||||
|
- [ ] Gérer la détection automatique de la date du jour
|
||||||
|
|
||||||
|
### Phase 3 : Tests locaux
|
||||||
|
- [ ] Tester la génération du QR code formateur
|
||||||
|
- [ ] Tester le scan et la validation de signature
|
||||||
|
- [ ] Vérifier l'enregistrement en base de données
|
||||||
|
- [ ] Tester les cas d'erreur (date invalide, doublon, etc.)
|
||||||
|
|
||||||
|
### Phase 4 : Déploiement
|
||||||
|
- [ ] Créer un checkpoint de la version testée
|
||||||
|
- [ ] Déployer sur le VPS
|
||||||
|
- [ ] Tester en production
|
||||||
|
- [ ] Valider avec l'utilisateur
|
||||||
|
|||||||
Reference in New Issue
Block a user