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 [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,6 +211,7 @@ export default function EmargementScan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedInscriptionId && datesDisponibles.length > 0 && (
|
{selectedInscriptionId && datesDisponibles.length > 0 && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label>Sélectionnez la date</Label>
|
<Label>Sélectionnez la date</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -232,6 +235,23 @@ export default function EmargementScan() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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
|
||||||
|
|||||||
@@ -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,18 +224,13 @@ 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">
|
|
||||||
{present ? (
|
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
Date {date.ordre}
|
Date {date.ordre}
|
||||||
@@ -248,22 +244,61 @@ export default function FormateurEmargement() {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs">🌅 Matin</span>
|
||||||
</div>
|
</div>
|
||||||
{!present && (
|
{!presentMatin && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleValiderPresence(inscrit.inscription.id, date.id)}
|
className="h-7 text-xs"
|
||||||
|
onClick={() => handleValiderPresence(inscrit.inscription.id, date.id, "matin")}
|
||||||
disabled={validating}
|
disabled={validating}
|
||||||
>
|
>
|
||||||
{validating ? (
|
{validating ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Valider"
|
"Valider"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}) : (
|
}) : (
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
9
todo.md
9
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user