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:
9
.manus/db/db-query-1769505503141.json
Normal file
9
.manus/db/db-query-1769505503141.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "ALTER TABLE attestations \nADD COLUMN signatureUrl VARCHAR(500),\nADD COLUMN signatureS3Key VARCHAR(500),\nADD COLUMN dateSigned TIMESTAMP;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE attestations \nADD COLUMN signatureUrl VARCHAR(500),\nADD COLUMN signatureS3Key VARCHAR(500),\nADD COLUMN dateSigned TIMESTAMP;",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 2073
|
||||
}
|
||||
9
.manus/db/db-query-1769505789171.json
Normal file
9
.manus/db/db-query-1769505789171.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "ALTER TABLE presences \nADD COLUMN signatureUrl VARCHAR(500),\nADD COLUMN signatureS3Key VARCHAR(500);",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE presences \nADD COLUMN signatureUrl VARCHAR(500),\nADD COLUMN signatureS3Key VARCHAR(500);",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 903
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
118
client/src/components/SignaturePad.tsx
Normal file
118
client/src/components/SignaturePad.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
192
client/src/pages/SignerAttestation.tsx
Normal file
192
client/src/pages/SignerAttestation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2220
drizzle/meta/0006_snapshot.json
Normal file
2220
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2234
drizzle/meta/0007_snapshot.json
Normal file
2234
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,20 @@
|
||||
"when": 1769347298041,
|
||||
"tag": "0005_shallow_bloodstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1769505491815,
|
||||
"tag": "0006_broad_sabra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1769506135258,
|
||||
"tag": "0007_lively_shiver_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -414,6 +414,10 @@ export const presences = mysqlTable("presences", {
|
||||
/** ID de l'utilisateur qui a validé (formateur ou admin) */
|
||||
validateurId: int("validateurId"),
|
||||
commentaire: text("commentaire"),
|
||||
/** URL de la signature manuscrite de l'apprenant */
|
||||
signatureUrl: varchar("signatureUrl", { length: 500 }),
|
||||
/** Clé S3 de la signature manuscrite */
|
||||
signatureS3Key: varchar("signatureS3Key", { length: 500 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -442,6 +446,12 @@ export const attestations = mysqlTable("attestations", {
|
||||
emailEnvoye: boolean("emailEnvoye").default(false).notNull(),
|
||||
/** Date d'envoi de l'email */
|
||||
dateEnvoiEmail: timestamp("dateEnvoiEmail"),
|
||||
/** URL de la signature manuscrite */
|
||||
signatureUrl: varchar("signatureUrl", { length: 500 }),
|
||||
/** Clé S3 de la signature manuscrite */
|
||||
signatureS3Key: varchar("signatureS3Key", { length: 500 }),
|
||||
/** Date de signature */
|
||||
dateSigned: timestamp("dateSigned"),
|
||||
dateGeneration: timestamp("dateGeneration").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"react-hook-form": "^7.64.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"recharts": "^2.15.4",
|
||||
"signature_pad": "^5.1.3",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.4.0",
|
||||
"superjson": "^1.13.3",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -235,6 +235,9 @@ importers:
|
||||
recharts:
|
||||
specifier: ^2.15.4
|
||||
version: 2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
signature_pad:
|
||||
specifier: ^5.1.3
|
||||
version: 5.1.3
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -5075,6 +5078,9 @@ packages:
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signature_pad@5.1.3:
|
||||
resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
@@ -11461,6 +11467,8 @@ snapshots:
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signature_pad@5.1.3: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
@@ -12,8 +12,8 @@ interface InscriptionExport {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
codeEtablissement: string | null;
|
||||
fonction: string | null;
|
||||
statut: string;
|
||||
dateInscription: Date;
|
||||
}
|
||||
@@ -30,8 +30,8 @@ interface SequenceInfo {
|
||||
interface ApprenantPresence {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
codeEtablissement: string | null;
|
||||
fonction: string | null;
|
||||
}
|
||||
|
||||
interface FeuillePresenceInfo {
|
||||
@@ -53,7 +53,7 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
'Nom': i.nom,
|
||||
'Prénom': i.prenom,
|
||||
'Email': i.email,
|
||||
'Code établissement': i.codeEtablissement,
|
||||
'Code établissement': i.codeEtablissement || '',
|
||||
'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'),
|
||||
@@ -136,7 +136,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.email,
|
||||
i.codeEtablissement,
|
||||
i.codeEtablissement || '',
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
]);
|
||||
@@ -222,7 +222,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
||||
const tableData = feuilleInfo.apprenants.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.codeEtablissement,
|
||||
i.codeEtablissement || '',
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'', // Signature matin
|
||||
'', // Signature après-midi
|
||||
|
||||
@@ -12,6 +12,8 @@ export async function validerPresence(params: {
|
||||
modeValidation: "qrcode" | "manuel";
|
||||
validateurId?: number;
|
||||
commentaire?: string;
|
||||
signatureUrl?: string;
|
||||
signatureS3Key?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
@@ -41,6 +43,9 @@ export async function validerPresence(params: {
|
||||
modeValidation: params.modeValidation,
|
||||
validateurId: params.validateurId,
|
||||
commentaire: params.commentaire,
|
||||
signatureUrl: params.signatureUrl,
|
||||
signatureS3Key: params.signatureS3Key,
|
||||
dateSigned: params.signatureUrl ? new Date() : undefined,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -2386,6 +2386,47 @@ export const appRouter = router({
|
||||
const { genererPreviewAttestation } = await import("./attestationService");
|
||||
return genererPreviewAttestation();
|
||||
}),
|
||||
|
||||
// Uploader la signature manuscrite d'un apprenant
|
||||
uploadSignature: publicProcedure
|
||||
.input(z.object({
|
||||
attestationId: z.number(),
|
||||
signatureDataUrl: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { storagePut } = await import("./storage");
|
||||
const { getDb } = await import("./db");
|
||||
const { attestations } = await import("../drizzle/schema");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Base de données non disponible");
|
||||
|
||||
// Convertir le data URL en buffer
|
||||
const base64Data = input.signatureDataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
const fileName = `signatures/attestation-${input.attestationId}-${Date.now()}.png`;
|
||||
|
||||
// Uploader vers S3
|
||||
const { url, key } = await storagePut(fileName, buffer, "image/png");
|
||||
|
||||
// Mettre à jour l'attestation dans la base de données
|
||||
await db
|
||||
.update(attestations)
|
||||
.set({
|
||||
signatureUrl: url,
|
||||
signatureS3Key: key,
|
||||
dateSigned: new Date(),
|
||||
})
|
||||
.where(eq(attestations.id, input.attestationId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
signatureUrl: url,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
|
||||
@@ -2436,6 +2477,7 @@ export const appRouter = router({
|
||||
modeValidation: z.enum(["qrcode", "manuel"]),
|
||||
validateurId: z.number().optional(),
|
||||
commentaire: z.string().optional(),
|
||||
signatureDataUrl: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { validerPresence } = await import("./presenceDb");
|
||||
@@ -2461,6 +2503,20 @@ export const appRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Uploader la signature en S3 si fournie
|
||||
let signatureUrl: string | undefined;
|
||||
let signatureS3Key: string | undefined;
|
||||
if (input.signatureDataUrl) {
|
||||
const { storagePut } = await import("./storage");
|
||||
const base64Data = input.signatureDataUrl.split(",")[1];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const randomSuffix = Math.random().toString(36).substring(7);
|
||||
const s3Key = `signatures/presence-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`;
|
||||
const { url } = await storagePut(s3Key, buffer, "image/png");
|
||||
signatureUrl = url;
|
||||
signatureS3Key = s3Key;
|
||||
}
|
||||
|
||||
const result = await validerPresence({
|
||||
inscriptionId: input.inscriptionId,
|
||||
dateFormationId: input.dateFormationId,
|
||||
@@ -2468,6 +2524,8 @@ export const appRouter = router({
|
||||
modeValidation: input.modeValidation,
|
||||
validateurId: input.validateurId,
|
||||
commentaire: input.commentaire,
|
||||
signatureUrl,
|
||||
signatureS3Key,
|
||||
});
|
||||
|
||||
// Vérifier si toutes les présences sont validées
|
||||
|
||||
14
todo.md
14
todo.md
@@ -1289,3 +1289,17 @@
|
||||
- [x] Corriger le bouton "œil" dans la page apprenants (formateur) qui n'affiche pas le détail
|
||||
- [x] Corriger la page vide lors de l'émargement via QR code sur smartphone
|
||||
- [x] Ajouter confirmation visuelle après émargement réussi (animation + heure)
|
||||
- [x] Implémenter signature manuscrite sur smartphone pour attestations (annulé - changement de besoin)
|
||||
- [ ] Implémenter signature manuscrite tactile pour émargement via QR code
|
||||
|
||||
## Signature tactile pour émargement via QR code
|
||||
|
||||
- [x] Créer le composant SignaturePad avec signature_pad
|
||||
- [x] Ajouter les champs de signature dans la table presences (signatureUrl, signatureS3Key, dateSigned)
|
||||
- [x] Intégrer SignaturePad dans EmargementScan.tsx
|
||||
- [x] Modifier presences.valider pour accepter signatureDataUrl
|
||||
- [x] Uploader la signature en S3 lors de la validation
|
||||
- [x] Stocker l'URL et la clé S3 dans la base de données
|
||||
- [ ] Intégrer les signatures dans la feuille de présence PDF (optionnel)
|
||||
- [ ] Tester le flux complet sur smartphone
|
||||
- [ ] Déployer sur le VPS
|
||||
|
||||
Reference in New Issue
Block a user