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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2485,11 +2485,12 @@ 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");
|
||||||
|
|
||||||
// Si mode QR code, vérifier le token
|
|
||||||
if (input.modeValidation === "qrcode" && input.token) {
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
|
// Si mode QR code, vérifier le 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
|
||||||
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) {
|
||||||
|
|||||||
14
todo.md
14
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user