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:
8
.manus/db/db-query-error-1769347309724.json
Normal file
8
.manus/db/db-query-error-1769347309724.json
Normal 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'"
|
||||
]
|
||||
}
|
||||
8
.manus/db/db-query-error-1769347313961.json
Normal file
8
.manus/db/db-query-error-1769347313961.json
Normal 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'"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}) : (
|
||||
|
||||
2199
drizzle/meta/0005_snapshot.json
Normal file
2199
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1768928661188,
|
||||
"tag": "0004_absent_wallow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1769347298041,
|
||||
"tag": "0005_shallow_bloodstrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
todo.md
9
todo.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user