Checkpoint: Implémentation de la signature manuscrite tactile pour l'émargement via QR code :

- Intégration du composant SignaturePad dans EmargementScan.tsx
- Ajout des champs de signature dans la table presences (signatureUrl, signatureS3Key, dateSigned)
- Modification de la procédure presences.valider pour accepter et uploader la signature en S3
- Stockage automatique de l'URL et de la clé S3 dans la base de données
- Correction des types nullables dans exportService.ts (codeEtablissement, fonction)
- Les apprenants doivent maintenant signer avec le doigt sur l'écran avant de valider leur présence
This commit is contained in:
Manus
2026-01-27 04:29:01 -05:00
parent b3b808145c
commit 1e915acd5b
16 changed files with 4936 additions and 11 deletions

View File

@@ -40,6 +40,7 @@ import Login from "./pages/Login";
import FormateurDashboard from "./pages/FormateurDashboard";
import FormateurEmargement from "./pages/FormateurEmargement";
import EmargementScan from "./pages/EmargementScan";
import SignerAttestation from "./pages/SignerAttestation";
import ForgotPassword from "./pages/ForgotPassword";
import ResetPassword from "./pages/ResetPassword";
@@ -84,6 +85,7 @@ function Router() {
<Route path={"/formateur"} component={FormateurDashboard} />
<Route path={"/formateur/emargement"} component={FormateurEmargement} />
<Route path={"/emargement/:token"} component={EmargementScan} />
<Route path={"/signer-attestation/:id"} component={SignerAttestation} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -0,0 +1,118 @@
import { useEffect, useRef, useState } from "react";
import SignaturePad from "signature_pad";
import { Button } from "./ui/button";
import { X, RotateCcw, Check } from "lucide-react";
interface SignaturePadComponentProps {
onSave: (dataUrl: string) => void;
onCancel: () => void;
}
export default function SignaturePadComponent({
onSave,
onCancel,
}: SignaturePadComponentProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const signaturePadRef = useRef<SignaturePad | null>(null);
const [isEmpty, setIsEmpty] = useState(true);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const signaturePad = new SignaturePad(canvas, {
backgroundColor: "rgb(255, 255, 255)",
penColor: "rgb(0, 0, 0)",
});
signaturePadRef.current = signaturePad;
// Redimensionner le canvas pour correspondre à sa taille d'affichage
const resizeCanvas = () => {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d")!.scale(ratio, ratio);
signaturePad.clear();
};
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// Détecter les changements
signaturePad.addEventListener("endStroke", () => {
setIsEmpty(signaturePad.isEmpty());
});
return () => {
window.removeEventListener("resize", resizeCanvas);
signaturePad.off();
};
}, []);
const handleClear = () => {
if (signaturePadRef.current) {
signaturePadRef.current.clear();
setIsEmpty(true);
}
};
const handleSave = () => {
if (signaturePadRef.current && !signaturePadRef.current.isEmpty()) {
const dataUrl = signaturePadRef.current.toDataURL("image/png");
onSave(dataUrl);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl animate-in fade-in zoom-in duration-300">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Signez votre attestation
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<p className="text-sm text-gray-600 mb-4">
Dessinez votre signature ci-dessous avec votre doigt ou votre stylet
</p>
<div className="border-2 border-gray-300 rounded-lg overflow-hidden bg-white">
<canvas
ref={canvasRef}
className="w-full touch-none"
style={{ height: "300px" }}
/>
</div>
<div className="mt-6 flex gap-3">
<Button
variant="outline"
onClick={handleClear}
disabled={isEmpty}
className="flex-1"
>
<RotateCcw className="w-4 h-4 mr-2" />
Effacer
</Button>
<Button
onClick={handleSave}
disabled={isEmpty}
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800"
>
<Check className="w-4 h-4 mr-2" />
Valider la signature
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -10,10 +10,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
import { CheckCircle2, Loader2, XCircle, PenTool } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "wouter";
import { toast } from "sonner";
import SignaturePadComponent from "@/components/SignaturePad";
export default function EmargementScan() {
const { token } = useParams<{ token: string }>();
@@ -24,6 +25,8 @@ export default function EmargementScan() {
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);
// Récupérer l'apprenant par email
const { data: apprenant, refetch: refetchApprenant } = trpc.apprenants.getByEmail.useQuery(
@@ -56,6 +59,13 @@ export default function EmargementScan() {
},
});
const handleSaveSignature = (dataUrl: string) => {
setSignatureDataUrl(dataUrl);
setShowSignaturePad(false);
// Valider automatiquement après la signature
handleValiderWithSignature(dataUrl);
};
const handleSearchEmail = async () => {
if (!email) {
toast.error("Veuillez saisir votre email");
@@ -70,12 +80,23 @@ export default function EmargementScan() {
return;
}
// Afficher le pad de signature au lieu de valider directement
setShowSignaturePad(true);
};
const handleValiderWithSignature = (dataUrl: string) => {
if (!selectedInscriptionId || !selectedDateId) {
toast.error("Veuillez sélectionner une date de formation");
return;
}
validerPresence({
token,
inscriptionId: selectedInscriptionId,
dateFormationId: selectedDateId,
periode: selectedPeriode,
modeValidation: "qrcode",
signatureDataUrl: dataUrl,
});
};
@@ -286,15 +307,18 @@ export default function EmargementScan() {
<Button
onClick={handleValider}
disabled={!selectedInscriptionId || !selectedDateId || validating}
className="w-full"
className="w-full h-14 text-lg bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800"
>
{validating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Validation en cours...
</>
) : (
"Valider ma présence"
<>
<PenTool className="mr-2 h-5 w-5" />
Signer et valider ma présence
</>
)}
</Button>
</>
@@ -319,6 +343,13 @@ export default function EmargementScan() {
)}
</CardContent>
</Card>
{showSignaturePad && (
<SignaturePadComponent
onSave={handleSaveSignature}
onCancel={() => setShowSignaturePad(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useState } from "react";
import { useParams } from "wouter";
import { trpc } from "@/lib/trpc";
import SignaturePadComponent from "@/components/SignaturePad";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { FileText, Loader2, CheckCircle2, PenTool } from "lucide-react";
export default function SignerAttestation() {
const params = useParams();
const attestationId = params.id ? parseInt(params.id) : null;
const [showSignaturePad, setShowSignaturePad] = useState(false);
const [isSigned, setIsSigned] = useState(false);
const { data: attestation, isLoading } = trpc.attestations.get.useQuery(
{
apprenantId: 0, // TODO: récupérer depuis le token
sequenceId: 0,
},
{
enabled: !!attestationId,
}
);
const uploadSignatureMutation = trpc.attestations.uploadSignature.useMutation({
onSuccess: () => {
setIsSigned(true);
setShowSignaturePad(false);
},
});
const handleSaveSignature = async (dataUrl: string) => {
if (!attestationId) return;
await uploadSignatureMutation.mutateAsync({
attestationId,
signatureDataUrl: dataUrl,
});
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
<Card className="p-8 max-w-md w-full text-center">
<Loader2 className="w-12 h-12 animate-spin mx-auto text-blue-600 mb-4" />
<p className="text-gray-600">Chargement de votre attestation...</p>
</Card>
</div>
);
}
if (!attestation) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
<Card className="p-8 max-w-md w-full text-center">
<FileText className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Attestation introuvable
</h1>
<p className="text-gray-600">
Le lien que vous avez suivi n'est pas valide ou a expiré.
</p>
</Card>
</div>
);
}
if (isSigned) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-50 flex items-center justify-center p-4">
<Card className="p-8 max-w-md w-full text-center animate-in fade-in zoom-in duration-500">
<div className="w-20 h-20 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center mx-auto mb-6 animate-in zoom-in duration-700">
<CheckCircle2 className="w-12 h-12 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Attestation signée !
</h1>
<p className="text-gray-600 mb-6">
Votre signature a été enregistrée avec succès. Vous recevrez votre
attestation signée par email.
</p>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800">
<strong>Date de signature :</strong>{" "}
{new Date().toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
<Card className="p-8 max-w-2xl w-full">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-full flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Signature de votre attestation
</h1>
<p className="text-gray-600">
Signez électroniquement votre attestation de formation
</p>
</div>
<div className="bg-white border-2 border-gray-200 rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Informations de l'attestation
</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Formation :</span>
<span className="font-medium text-gray-900">
{/* TODO: afficher le nom de la formation */}
Formation Manager Itinova
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Séquence :</span>
<span className="font-medium text-gray-900">
{/* TODO: afficher le nom de la séquence */}
Groupe A
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date de génération :</span>
<span className="font-medium text-gray-900">
{attestation.dateGeneration
? new Date(attestation.dateGeneration).toLocaleDateString(
"fr-FR"
)
: "N/A"}
</span>
</div>
</div>
</div>
{attestation.urlPdf && (
<div className="mb-6">
<Button
variant="outline"
className="w-full"
onClick={() => window.open(attestation.urlPdf!, "_blank")}
>
<FileText className="w-4 h-4 mr-2" />
Prévisualiser l'attestation
</Button>
</div>
)}
<Button
onClick={() => setShowSignaturePad(true)}
disabled={uploadSignatureMutation.isPending}
className="w-full h-14 text-lg bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800"
>
{uploadSignatureMutation.isPending ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Enregistrement...
</>
) : (
<>
<PenTool className="w-5 h-5 mr-2" />
Signer l'attestation
</>
)}
</Button>
<p className="text-xs text-gray-500 text-center mt-4">
En signant, vous certifiez avoir suivi cette formation et acceptez
que votre signature soit intégrée à l'attestation.
</p>
</Card>
{showSignaturePad && (
<SignaturePadComponent
onSave={handleSaveSignature}
onCancel={() => setShowSignaturePad(false)}
/>
)}
</div>
);
}