Checkpoint: Implémentation de l'émargement numérique avec deux signatures par jour (matin et après-midi):

- Ajout du champ 'periode' dans la table presences
- Modification des procédures backend pour gérer les deux périodes
- Mise à jour de l'interface EmargementScan avec choix de période
- Mise à jour de l'interface FormateurEmargement avec validation séparée matin/après-midi
- La feuille de présence PDF était déjà configurée avec deux colonnes
This commit is contained in:
Manus
2026-01-25 08:26:42 -05:00
parent 1460b41f2d
commit 3e14adcf70
10 changed files with 2359 additions and 63 deletions

View File

@@ -0,0 +1,8 @@
{
"query": "ALTER TABLE `presences` ADD `periode` enum('matin','apres_midi') DEFAULT 'matin' NOT NULL;\nALTER TABLE `presences` ADD `commentaire` text;",
"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` ADD `periode` enum('matin','apres_midi') DEFAULT 'matin' NOT NULL;\nALTER TABLE `presences` ADD `commentaire` text;",
"returncode": 1,
"logs": [
"ERROR 1060 (42S21) at line 2: Duplicate column name 'commentaire'"
]
}

View File

@@ -0,0 +1,8 @@
{
"query": "ALTER TABLE `presences` ADD `periode` enum('matin','apres_midi') DEFAULT 'matin' NOT NULL;",
"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` ADD `periode` enum('matin','apres_midi') DEFAULT 'matin' NOT NULL;",
"returncode": 1,
"logs": [
"ERROR 1060 (42S21) at line 1: Duplicate column name 'periode'"
]
}

View File

@@ -20,6 +20,7 @@ export default function EmargementScan() {
const [email, setEmail] = useState("");
const [selectedInscriptionId, setSelectedInscriptionId] = useState<number | null>(null);
const [selectedDateId, setSelectedDateId] = useState<number | null>(null);
const [selectedPeriode, setSelectedPeriode] = useState<"matin" | "apres_midi">("matin");
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -70,6 +71,7 @@ export default function EmargementScan() {
token,
inscriptionId: selectedInscriptionId,
dateFormationId: selectedDateId,
periode: selectedPeriode,
modeValidation: "qrcode",
});
};
@@ -209,29 +211,47 @@ export default function EmargementScan() {
</div>
{selectedInscriptionId && datesDisponibles.length > 0 && (
<div>
<Label>Sélectionnez la date</Label>
<Select
value={selectedDateId?.toString() || ""}
onValueChange={(value) => setSelectedDateId(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Choisir une date..." />
</SelectTrigger>
<SelectContent>
{datesDisponibles.map((date: any) => (
<SelectItem key={date.id} value={date.id.toString()}>
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<>
<div>
<Label>Sélectionnez la date</Label>
<Select
value={selectedDateId?.toString() || ""}
onValueChange={(value) => setSelectedDateId(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Choisir une date..." />
</SelectTrigger>
<SelectContent>
{datesDisponibles.map((date: any) => (
<SelectItem key={date.id} value={date.id.toString()}>
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Période de la journée</Label>
<Select
value={selectedPeriode}
onValueChange={(value) => setSelectedPeriode(value as "matin" | "apres_midi")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="matin">🌅 Matin</SelectItem>
<SelectItem value="apres_midi">🌆 Après-midi</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<Button

View File

@@ -76,19 +76,20 @@ export default function FormateurEmargement() {
generateQR({ sequenceId: selectedSequenceId });
};
const handleValiderPresence = (inscriptionId: number, dateFormationId: number) => {
const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => {
validerPresence({
inscriptionId,
dateFormationId,
periode,
modeValidation: "manuel",
validateurId: user?.id,
});
};
// Vérifier si un apprenant a validé sa présence pour une date
const isPresent = (inscriptionId: number, dateFormationId: number) => {
// Vérifier si un apprenant a validé sa présence pour une date et une période
const isPresent = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => {
return presences?.some(
(p) => p.inscriptionId === inscriptionId && p.dateFormationId === dateFormationId
(p) => p.inscriptionId === inscriptionId && p.dateFormationId === dateFormationId && p.periode === periode
);
};
@@ -223,46 +224,80 @@ export default function FormateurEmargement() {
<div className="space-y-2">
{inscrit.dates && Array.isArray(inscrit.dates) && inscrit.dates.length > 0 ? inscrit.dates.map((date: any) => {
if (!date || !date.id) return null;
const present = isPresent(inscrit.inscription.id, date.id);
const presentMatin = isPresent(inscrit.inscription.id, date.id, "matin");
const presentApresMidi = isPresent(inscrit.inscription.id, date.id, "apres_midi");
return (
<div
key={date.id}
className="flex items-center justify-between bg-muted/50 p-3 rounded"
className="bg-muted/50 p-3 rounded space-y-2"
>
<div className="flex items-center gap-3">
{present ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
<div>
<p className="text-sm font-medium">
Date {date.ordre}
</p>
<p className="text-xs text-muted-foreground">
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
<div>
<p className="text-sm font-medium">
Date {date.ordre}
</p>
<p className="text-xs text-muted-foreground">
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
{!present && (
<Button
size="sm"
variant="outline"
onClick={() => handleValiderPresence(inscrit.inscription.id, date.id)}
disabled={validating}
>
{validating ? (
<Loader2 className="h-4 w-4 animate-spin" />
{/* Matin */}
<div className="flex items-center justify-between pl-4">
<div className="flex items-center gap-2">
{presentMatin ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
"Valider"
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</Button>
)}
<span className="text-xs">🌅 Matin</span>
</div>
{!presentMatin && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => handleValiderPresence(inscrit.inscription.id, date.id, "matin")}
disabled={validating}
>
{validating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Valider"
)}
</Button>
)}
</div>
{/* Après-midi */}
<div className="flex items-center justify-between pl-4">
<div className="flex items-center gap-2">
{presentApresMidi ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-xs">🌆 Après-midi</span>
</div>
{!presentApresMidi && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => handleValiderPresence(inscrit.inscription.id, date.id, "apres_midi")}
disabled={validating}
>
{validating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Valider"
)}
</Button>
)}
</div>
</div>
);
}) : (

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1768928661188,
"tag": "0004_absent_wallow",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1769347298041,
"tag": "0005_shallow_bloodstrike",
"breakpoints": true
}
]
}

View File

@@ -406,11 +406,14 @@ export const presences = mysqlTable("presences", {
id: int("id").autoincrement().primaryKey(),
inscriptionId: int("inscriptionId").notNull(),
dateFormationId: int("dateFormationId").notNull(),
/** Période de la journée (matin ou après-midi) */
periode: mysqlEnum("periode", ["matin", "apres_midi"]).notNull().default("matin"),
heurePresence: timestamp("heurePresence").defaultNow().notNull(),
/** Mode de validation (qrcode, manuel) */
modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(),
/** ID de l'utilisateur qui a validé (formateur ou admin) */
validateurId: int("validateurId"),
commentaire: text("commentaire"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

View File

@@ -8,6 +8,7 @@ import { getDb } from "./db";
export async function validerPresence(params: {
inscriptionId: number;
dateFormationId: number;
periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel";
validateurId?: number;
commentaire?: string;
@@ -15,26 +16,28 @@ export async function validerPresence(params: {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier si la présence existe déjà
// Vérifier si la présence existe déjà pour cette période
const presenceExistante = await db
.select()
.from(presences)
.where(
and(
eq(presences.inscriptionId, params.inscriptionId),
eq(presences.dateFormationId, params.dateFormationId)
eq(presences.dateFormationId, params.dateFormationId),
eq(presences.periode, params.periode)
)
)
.limit(1);
if (presenceExistante.length > 0) {
throw new Error("Présence déjà validée pour cette date");
throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`);
}
// Créer la présence
await db.insert(presences).values({
inscriptionId: params.inscriptionId,
dateFormationId: params.dateFormationId,
periode: params.periode,
heurePresence: new Date(),
modeValidation: params.modeValidation,
validateurId: params.validateurId,
@@ -56,6 +59,7 @@ export async function getPresencesBySequence(sequenceId: number) {
presenceId: presences.id,
inscriptionId: presences.inscriptionId,
dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,
@@ -87,6 +91,7 @@ export async function getPresencesByInscription(inscriptionId: number) {
.select({
presenceId: presences.id,
dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,

View File

@@ -2432,6 +2432,7 @@ export const appRouter = router({
token: z.string().optional(),
inscriptionId: z.number(),
dateFormationId: z.number(),
periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]),
validateurId: z.number().optional(),
commentaire: z.string().optional(),
@@ -2463,6 +2464,7 @@ export const appRouter = router({
const result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
periode: input.periode,
modeValidation: input.modeValidation,
validateurId: input.validateurId,
commentaire: input.commentaire,

View File

@@ -1249,4 +1249,13 @@
- [x] Identifier le fichier AdminSequenceInscrits.tsx
- [x] Appliquer des couleurs pastel aux boutons d'actions
- [x] Déployer sur le VPS
## Permettre deux signatures par jour pour l'émargement (matin et après-midi)
- [x] Analyser le système d'émargement actuel (schéma DB, backend, frontend)
- [x] Modifier le schéma de base de données pour ajouter un champ "periode" (matin/apres-midi)
- [x] Adapter les procédures backend pour gérer les deux périodes
- [x] Modifier l'interface d'émargement pour proposer le choix matin/après-midi (EmargementScan + FormateurEmargement)
- [x] Vérifier l'export de feuille de présence (déjà configuré avec deux colonnes)
- [ ] Déployer sur le VPS