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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1768928661188, "when": 1768928661188,
"tag": "0004_absent_wallow", "tag": "0004_absent_wallow",
"breakpoints": true "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(), id: int("id").autoincrement().primaryKey(),
inscriptionId: int("inscriptionId").notNull(), inscriptionId: int("inscriptionId").notNull(),
dateFormationId: int("dateFormationId").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(), heurePresence: timestamp("heurePresence").defaultNow().notNull(),
/** Mode de validation (qrcode, manuel) */ /** Mode de validation (qrcode, manuel) */
modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(), modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(),
/** ID de l'utilisateur qui a validé (formateur ou admin) */ /** ID de l'utilisateur qui a validé (formateur ou admin) */
validateurId: int("validateurId"), validateurId: int("validateurId"),
commentaire: text("commentaire"),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); });

View File

@@ -8,6 +8,7 @@ import { getDb } from "./db";
export async function validerPresence(params: { export async function validerPresence(params: {
inscriptionId: number; inscriptionId: number;
dateFormationId: number; dateFormationId: number;
periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel"; modeValidation: "qrcode" | "manuel";
validateurId?: number; validateurId?: number;
commentaire?: string; commentaire?: string;
@@ -15,26 +16,28 @@ export async function validerPresence(params: {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); 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 const presenceExistante = await db
.select() .select()
.from(presences) .from(presences)
.where( .where(
and( and(
eq(presences.inscriptionId, params.inscriptionId), eq(presences.inscriptionId, params.inscriptionId),
eq(presences.dateFormationId, params.dateFormationId) eq(presences.dateFormationId, params.dateFormationId),
eq(presences.periode, params.periode)
) )
) )
.limit(1); .limit(1);
if (presenceExistante.length > 0) { 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 // Créer la présence
await db.insert(presences).values({ await db.insert(presences).values({
inscriptionId: params.inscriptionId, inscriptionId: params.inscriptionId,
dateFormationId: params.dateFormationId, dateFormationId: params.dateFormationId,
periode: params.periode,
heurePresence: new Date(), heurePresence: new Date(),
modeValidation: params.modeValidation, modeValidation: params.modeValidation,
validateurId: params.validateurId, validateurId: params.validateurId,
@@ -56,6 +59,7 @@ export async function getPresencesBySequence(sequenceId: number) {
presenceId: presences.id, presenceId: presences.id,
inscriptionId: presences.inscriptionId, inscriptionId: presences.inscriptionId,
dateFormationId: presences.dateFormationId, dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence, heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation, modeValidation: presences.modeValidation,
validateurId: presences.validateurId, validateurId: presences.validateurId,
@@ -87,6 +91,7 @@ export async function getPresencesByInscription(inscriptionId: number) {
.select({ .select({
presenceId: presences.id, presenceId: presences.id,
dateFormationId: presences.dateFormationId, dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence, heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation, modeValidation: presences.modeValidation,
validateurId: presences.validateurId, validateurId: presences.validateurId,

View File

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

View File

@@ -1249,4 +1249,13 @@
- [x] Identifier le fichier AdminSequenceInscrits.tsx - [x] Identifier le fichier AdminSequenceInscrits.tsx
- [x] Appliquer des couleurs pastel aux boutons d'actions - [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 - [ ] Déployer sur le VPS