feat: améliorer inscription et export apprenants
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m21s

This commit is contained in:
manus-admin
2026-10-02 11:11:50 +02:00
parent 2cac56b8c6
commit a7845f5b06
13 changed files with 370 additions and 50 deletions

View File

@@ -69,7 +69,7 @@ export default function AdminApprenantDetail() {
case 'chef_service':
return 'Chefs de service';
case 'tous':
return 'Tous';
return 'Tous managers';
default:
return 'Autre';
}
@@ -120,7 +120,7 @@ export default function AdminApprenantDetail() {
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Fonction</div>
<div className="text-lg">{getFonctionLabel(apprenant.fonction)}</div>
<div className="text-lg">{getFonctionLabel(apprenant.fonction ?? "")}</div>
</div>
</div>
</CardContent>

View File

@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye } from "lucide-react";
import { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye, Download } from "lucide-react";
import { useState, useMemo } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
@@ -67,6 +67,27 @@ export default function AdminApprenants() {
},
});
const exportExcelMutation = trpc.apprenants.exportExcel.useMutation({
onSuccess: (result) => {
const blob = new Blob(
[Uint8Array.from(atob(result.buffer), (character) => character.charCodeAt(0))],
{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("Export Excel des apprenants téléchargé");
},
onError: (error) => {
toast.error("Erreur lors de l'export : " + error.message);
},
});
const resetForm = () => {
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" });
setEditingId(null);
@@ -99,6 +120,10 @@ export default function AdminApprenants() {
}
};
const handleExportExcel = () => {
exportExcelMutation.mutate();
};
// Filtrer les apprenants
const filteredApprenants = useMemo(() => {
if (!apprenants) return [];
@@ -108,7 +133,7 @@ export default function AdminApprenants() {
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase());
(apprenant.codeEtablissement ?? "").toLowerCase().includes(searchTerm.toLowerCase());
const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction;
@@ -127,7 +152,16 @@ export default function AdminApprenants() {
</p>
</div>
{isAdmin && (
<Dialog open={open} onOpenChange={(isOpen) => {
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={handleExportExcel}
disabled={exportExcelMutation.isPending}
>
<Download className="w-4 h-4 mr-2" />
{exportExcelMutation.isPending ? "Export en cours..." : "Exporter la liste Excel"}
</Button>
<Dialog open={open} onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) resetForm();
}}>
@@ -178,6 +212,7 @@ export default function AdminApprenants() {
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="jean.dupont@example.com"
autoComplete="off"
required
/>
</div>
@@ -215,7 +250,8 @@ export default function AdminApprenants() {
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Dialog>
</div>
)}
</div>

View File

@@ -154,7 +154,7 @@ export default function AdminCalendrier() {
const labels: Record<string, string> = {
directeurs: "Directeurs",
chefs_service: "Chefs de service",
tous: "Tous",
tous: "Tous managers",
autre: "Autre",
};
return labels[publicCible] || publicCible;

View File

@@ -68,6 +68,7 @@ export default function AdminRapportPublicCible() {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
tous: "Tous managers",
autre: "Autre",
};
return labels[publicCible as keyof typeof labels] || "Autre";
@@ -77,6 +78,7 @@ export default function AdminRapportPublicCible() {
const colors = {
directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800",
tous: "bg-purple-100 text-purple-800",
autre: "bg-gray-100 text-gray-800",
};
return colors[publicCible as keyof typeof colors] || colors.autre;

View File

@@ -20,6 +20,18 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
type EmailActionType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6";
const EMAIL_ACTION_LABELS: Record<EmailActionType, string> = {
teaser: "teaser",
rappel1: "de rappel 1",
rappel2: "de rappel 2",
rappel3: "de rappel 3",
rappel4: "de rappel 4",
rappel5: "de rappel 5",
rappel6: "de rappel 6",
};
export default function AdminSequenceInscrits() {
const [, setLocation] = useLocation();
const [, params] = useRoute("/admin/sequences/:id/inscrits");
@@ -29,6 +41,7 @@ export default function AdminSequenceInscrits() {
const colors = {
directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800",
tous: "bg-purple-100 text-purple-800",
autre: "bg-gray-100 text-gray-800",
};
return colors[publicCible as keyof typeof colors] || colors.autre;
@@ -38,6 +51,7 @@ export default function AdminSequenceInscrits() {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
tous: "Tous managers",
autre: "Autre",
};
return labels[publicCible as keyof typeof labels] || "Autre";
@@ -52,7 +66,7 @@ export default function AdminSequenceInscrits() {
const [isAddInscritOpen, setIsAddInscritOpen] = useState(false);
const [selectedApprenantId, setSelectedApprenantId] = useState<number | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewType, setPreviewType] = useState<"teaser" | "rappel" | "rappel_j1">("teaser");
const [previewType, setPreviewType] = useState<EmailActionType>("teaser");
const utils = trpc.useUtils();
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
@@ -168,8 +182,8 @@ export default function AdminSequenceInscrits() {
exportFeuilleMutation.mutate({ sequenceId });
};
const handleSendEmails = (type: "teaser" | "rappel" | "rappel_j1") => {
const typeLabel = type === "teaser" ? "teaser" : type === "rappel" ? "de rappel J-7" : "de rappel J-1";
const handleSendEmails = (type: EmailActionType) => {
const typeLabel = EMAIL_ACTION_LABELS[type];
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) {
sendEmailsMutation.mutate({ sequenceId, type });
}
@@ -569,7 +583,7 @@ export default function AdminSequenceInscrits() {
<TableCell>{item.apprenant.prenom}</TableCell>
<TableCell>{item.apprenant.email}</TableCell>
<TableCell>{item.apprenant.codeEtablissement}</TableCell>
<TableCell>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
<TableCell>{getFonctionLabel(item.apprenant.fonction ?? "")}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDate(item.inscription.dateInscription)}
</TableCell>
@@ -617,7 +631,7 @@ export default function AdminSequenceInscrits() {
sequenceId={sequenceId}
onSuccess={() => {
setIsAddInscritOpen(false);
utils.inscriptions.getBySequence.invalidate({ sequenceId });
utils.inscriptions.listBySequence.invalidate({ sequenceId });
toast.success("Apprenant inscrit avec succès");
}}
onCancel={() => setIsAddInscritOpen(false)}
@@ -630,7 +644,7 @@ export default function AdminSequenceInscrits() {
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Aperçu de l'email {previewType === "teaser" ? "teaser" : previewType === "rappel" ? "de rappel J-7" : "de rappel J-1"}
Aperçu de l'email {EMAIL_ACTION_LABELS[previewType]}
</DialogTitle>
<DialogDescription>
{loadingPreview ? (

View File

@@ -249,7 +249,7 @@ export default function AdminSequences() {
capaciteMax: parseInt(formData.capaciteMax),
dateBlocage: convertLocalToUTC(formData.dateBlocage),
statut: formData.statut,
formateurId: formData.formateur ? parseInt(formData.formateur) : null,
formateurId: formData.formateur || null,
dates: formData.dates.map(d => ({
...d,
dateDebut: convertLocalToUTC(d.dateDebut),
@@ -429,7 +429,7 @@ export default function AdminSequences() {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
tous: "Tous",
tous: "Tous managers",
autre: "Autre",
};
return labels[publicCible as keyof typeof labels] || "Autre";
@@ -535,7 +535,7 @@ export default function AdminSequences() {
<SelectContent>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous</SelectItem>
<SelectItem value="tous">Tous managers</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
@@ -1029,7 +1029,7 @@ export default function AdminSequences() {
<SelectContent>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous</SelectItem>
<SelectItem value="tous">Tous managers</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>

View File

@@ -12,6 +12,7 @@ import { fr } from "date-fns/locale";
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { APP_LOGO, APP_TITLE } from "@/const";
import { sortSequencesForRegistration } from "@shared/registration";
export default function Inscription() {
const [, params] = useRoute("/inscription/:lien");
@@ -70,10 +71,11 @@ export default function Inscription() {
"chef_service": "chef_service",
"autre": "autre"
};
if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) {
const cibleFonction = publicCibleMap[selectedSequence.publicCible];
if (cibleFonction && cibleFonction !== formData.fonction) {
const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" :
selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Autre";
selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Tous managers";
const fonctionLabel = formData.fonction === "directeur" ? "Directeur" :
formData.fonction === "chef_service" ? "Chef de service" : "Autre";
@@ -116,24 +118,13 @@ export default function Inscription() {
}
};
// Calculer les places disponibles pour chaque sequence
// Les sessions disponibles sont affichées en premier, puis les listes d'attente et les sessions fermées.
const sessionsAvecPlaces = useMemo(() => {
if (!sequences) return [];
return sequences.map(sequence => {
const now = new Date();
const dateBlocage = new Date(sequence.dateBlocage);
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits || 0));
return {
...sequence,
isBloquee,
placesRestantes,
};
});
return sortSequencesForRegistration(sequences ?? []);
}, [sequences]);
const selectedSequence = sessionsAvecPlaces.find((sequence) => sequence.id === selectedSequenceId);
const formatDate = (dateValue: Date | string) => {
if (!dateValue) return "N/A";
@@ -259,9 +250,9 @@ export default function Inscription() {
{/* Sessions disponibles */}
<Card>
<CardHeader>
<CardTitle>Sessions disponibles</CardTitle>
<CardTitle>1. Choisissez votre session</CardTitle>
<CardDescription>
Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum)
Les sessions avec des places disponibles sont affichées en premier.
</CardDescription>
</CardHeader>
<CardContent>
@@ -270,14 +261,13 @@ export default function Inscription() {
{sessionsAvecPlaces.map((sequence) => (
<div
key={sequence.id}
className={`border rounded-lg p-4 cursor-pointer transition-all ${
className={`border rounded-lg p-4 transition-all ${
selectedSequenceId === sequence.id
? "border-blue-500 bg-blue-50"
? "border-blue-600 bg-blue-50 ring-2 ring-blue-200 shadow-md"
: sequence.isBloquee
? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
? "border-gray-300 bg-gray-50 opacity-60"
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
}`}
onClick={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)}
>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
@@ -299,7 +289,7 @@ export default function Inscription() {
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"}</span>
<span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : sequence.publicCible === "tous" ? "Tous managers" : "Autre"}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
@@ -326,6 +316,24 @@ export default function Inscription() {
)}
</div>
</div>
<Button
type="button"
className="mt-4 w-full"
variant={selectedSequenceId === sequence.id ? "secondary" : "default"}
disabled={sequence.isBloquee}
onClick={() => setSelectedSequenceId(sequence.id)}
>
{selectedSequenceId === sequence.id ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Session sélectionnée
</>
) : sequence.isBloquee ? (
"Inscriptions fermées"
) : (
"Choisir cette session"
)}
</Button>
</div>
))}
</div>
@@ -341,13 +349,16 @@ export default function Inscription() {
{selectedSequenceId && (
<Card>
<CardHeader>
<CardTitle>Vos informations</CardTitle>
<CardTitle>2. Vos informations</CardTitle>
<CardDescription>
Complétez le formulaire pour finaliser votre inscription
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<span className="font-semibold">Session sélectionnée :</span> {selectedSequence?.nom}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nom">Nom *</Label>
@@ -414,12 +425,12 @@ export default function Inscription() {
<Button
type="submit"
className="w-full"
className="w-full text-base font-semibold shadow-sm"
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
>
{inscriptionMutation.isPending || createApprenantMutation.isPending
? "Inscription en cours..."
: "Confirmer mon inscription"}
: "Finaliser mon inscription"}
</Button>
</form>
</CardContent>