Checkpoint: Affichage des informations de signature dans la fenêtre d'émargement numérique :

- Ajout des champs signatureUrl, signatureS3Key et dateSigned dans getPresencesByInscription
- Affichage du nom du signataire, date/heure de validation à côté de chaque puce matin/après-midi
- Icône cliquable pour visualiser la signature en grand format
- Modale Dialog pour afficher la signature manuscrite
- Fonction getPresenceInfo pour récupérer les détails de présence
- Interface moderne avec icône de stylo SVG pour les signatures
This commit is contained in:
Manus
2026-01-27 07:38:59 -05:00
parent 5356819ceb
commit feb708bb3e
4 changed files with 119 additions and 7 deletions

View File

@@ -93,6 +93,17 @@ export default function FormateurEmargement() {
); );
}; };
// Récupérer les informations de présence pour une date et une période
const getPresenceInfo = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => {
return presences?.find(
(p) => p.inscriptionId === inscriptionId && p.dateFormationId === dateFormationId && p.periode === periode
);
};
// État pour la modale de signature
const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
const [showSignatureDialog, setShowSignatureDialog] = useState(false);
if (loadingSequences) { if (loadingSequences) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
@@ -247,13 +258,46 @@ export default function FormateurEmargement() {
{/* Matin */} {/* Matin */}
<div className="flex items-center justify-between pl-4"> <div className="flex items-center justify-between pl-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-1">
{presentMatin ? ( {presentMatin ? (
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
) : ( ) : (
<Circle className="h-4 w-4 text-muted-foreground" /> <Circle className="h-4 w-4 text-muted-foreground" />
)} )}
<span className="text-xs">🌅 Matin</span> <span className="text-xs">🌅 Matin</span>
{(() => {
const presenceInfo = getPresenceInfo(inscrit.inscription.id, date.id, "matin");
if (presenceInfo) {
return (
<div className="flex items-center gap-2 ml-2 text-xs text-muted-foreground">
<span>• {inscrit.apprenant?.prenom} {inscrit.apprenant?.nom}</span>
<span>• {new Date(presenceInfo.heurePresence).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})}</span>
{presenceInfo.signatureUrl && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => {
setSelectedSignature(presenceInfo.signatureUrl!);
setShowSignatureDialog(true);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-blue-600">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</svg>
</Button>
)}
</div>
);
}
return null;
})()}
</div> </div>
{!presentMatin && ( {!presentMatin && (
<Button <Button
@@ -274,13 +318,46 @@ export default function FormateurEmargement() {
{/* Après-midi */} {/* Après-midi */}
<div className="flex items-center justify-between pl-4"> <div className="flex items-center justify-between pl-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-1">
{presentApresMidi ? ( {presentApresMidi ? (
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
) : ( ) : (
<Circle className="h-4 w-4 text-muted-foreground" /> <Circle className="h-4 w-4 text-muted-foreground" />
)} )}
<span className="text-xs">🌆 Après-midi</span> <span className="text-xs">🌆 Après-midi</span>
{(() => {
const presenceInfo = getPresenceInfo(inscrit.inscription.id, date.id, "apres_midi");
if (presenceInfo) {
return (
<div className="flex items-center gap-2 ml-2 text-xs text-muted-foreground">
<span>• {inscrit.apprenant?.prenom} {inscrit.apprenant?.nom}</span>
<span>• {new Date(presenceInfo.heurePresence).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})}</span>
{presenceInfo.signatureUrl && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => {
setSelectedSignature(presenceInfo.signatureUrl!);
setShowSignatureDialog(true);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-blue-600">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</svg>
</Button>
)}
</div>
);
}
return null;
})()}
</div> </div>
{!presentApresMidi && ( {!presentApresMidi && (
<Button <Button
@@ -337,6 +414,29 @@ export default function FormateurEmargement() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog Signature */}
<Dialog open={showSignatureDialog} onOpenChange={setShowSignatureDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Signature de l'apprenant</DialogTitle>
<DialogDescription>
Signature manuscrite capturée lors de la validation de présence
</DialogDescription>
</DialogHeader>
{selectedSignature && (
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-full border-2 border-gray-300 rounded-lg p-4 bg-white">
<img
src={selectedSignature}
alt="Signature"
className="w-full h-auto"
/>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -100,6 +100,9 @@ export async function getPresencesByInscription(inscriptionId: number) {
modeValidation: presences.modeValidation, modeValidation: presences.modeValidation,
validateurId: presences.validateurId, validateurId: presences.validateurId,
commentaire: presences.commentaire, commentaire: presences.commentaire,
signatureUrl: presences.signatureUrl,
signatureS3Key: presences.signatureS3Key,
dateSigned: presences.dateSigned,
dateDebut: datesFormation.dateDebut, dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin, dateFin: datesFormation.dateFin,
}) })

View File

@@ -2485,10 +2485,11 @@ export const appRouter = router({
const { sequences, inscriptions } = await import("../drizzle/schema"); const { sequences, inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm"); const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// 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) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Récupérer l'inscription pour vérifier la séquence // Récupérer l'inscription pour vérifier la séquence
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);

14
todo.md
View File

@@ -1306,8 +1306,16 @@
## Stockage local des signatures d'émargement ## Stockage local des signatures d'émargement
- [ ] Modifier routers.ts pour stocker les signatures localement au lieu de S3 - [x] Modifier routers.ts pour stocker les signatures localement au lieu de S3
- [ ] Créer la structure de dossiers uploads/signatures/[formation-id]-[sequence-id]/ - [x] Créer la structure de dossiers uploads/signatures/[formation-id]-[sequence-id]/
- [ ] Générer des noms de fichiers uniques pour chaque signature - [x] Générer des noms de fichiers uniques pour chaque signature
- [ ] Configurer Nginx pour servir les fichiers de signatures - [ ] Configurer Nginx pour servir les fichiers de signatures
- [ ] Tester le flux complet sur le VPS - [ ] Tester le flux complet sur le VPS
## Affichage des signatures dans l'émargement numérique
- [ ] Modifier l'API pour récupérer les présences avec signatures
- [ ] Afficher nom du signataire, date/heure et icône à côté de chaque puce matin/après-midi
- [ ] Ajouter une modale pour visualiser la signature en grand format
- [ ] Tester l'affichage sur smartphone
- [ ] Déployer sur le VPS