feat: améliorer inscription et export apprenants
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m21s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m21s
This commit is contained in:
@@ -69,7 +69,7 @@ export default function AdminApprenantDetail() {
|
|||||||
case 'chef_service':
|
case 'chef_service':
|
||||||
return 'Chefs de service';
|
return 'Chefs de service';
|
||||||
case 'tous':
|
case 'tous':
|
||||||
return 'Tous';
|
return 'Tous managers';
|
||||||
default:
|
default:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ export default function AdminApprenantDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Fonction</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { trpc } from "@/lib/trpc";
|
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 { useState, useMemo } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { toast } from "sonner";
|
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 = () => {
|
const resetForm = () => {
|
||||||
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" });
|
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" });
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -99,6 +120,10 @@ export default function AdminApprenants() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportExcel = () => {
|
||||||
|
exportExcelMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
// Filtrer les apprenants
|
// Filtrer les apprenants
|
||||||
const filteredApprenants = useMemo(() => {
|
const filteredApprenants = useMemo(() => {
|
||||||
if (!apprenants) return [];
|
if (!apprenants) return [];
|
||||||
@@ -108,7 +133,7 @@ export default function AdminApprenants() {
|
|||||||
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
apprenant.email.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;
|
const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction;
|
||||||
|
|
||||||
@@ -127,7 +152,16 @@ export default function AdminApprenants() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{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);
|
setOpen(isOpen);
|
||||||
if (!isOpen) resetForm();
|
if (!isOpen) resetForm();
|
||||||
}}>
|
}}>
|
||||||
@@ -178,6 +212,7 @@ export default function AdminApprenants() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
placeholder="jean.dupont@example.com"
|
placeholder="jean.dupont@example.com"
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +250,8 @@ export default function AdminApprenants() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function AdminCalendrier() {
|
|||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
directeurs: "Directeurs",
|
directeurs: "Directeurs",
|
||||||
chefs_service: "Chefs de service",
|
chefs_service: "Chefs de service",
|
||||||
tous: "Tous",
|
tous: "Tous managers",
|
||||||
autre: "Autre",
|
autre: "Autre",
|
||||||
};
|
};
|
||||||
return labels[publicCible] || publicCible;
|
return labels[publicCible] || publicCible;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function AdminRapportPublicCible() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
directeur: "Directeur",
|
directeur: "Directeur",
|
||||||
chef_service: "Chef de service",
|
chef_service: "Chef de service",
|
||||||
|
tous: "Tous managers",
|
||||||
autre: "Autre",
|
autre: "Autre",
|
||||||
};
|
};
|
||||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||||
@@ -77,6 +78,7 @@ export default function AdminRapportPublicCible() {
|
|||||||
const colors = {
|
const colors = {
|
||||||
directeur: "bg-blue-100 text-blue-800",
|
directeur: "bg-blue-100 text-blue-800",
|
||||||
chef_service: "bg-green-100 text-green-800",
|
chef_service: "bg-green-100 text-green-800",
|
||||||
|
tous: "bg-purple-100 text-purple-800",
|
||||||
autre: "bg-gray-100 text-gray-800",
|
autre: "bg-gray-100 text-gray-800",
|
||||||
};
|
};
|
||||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} 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() {
|
export default function AdminSequenceInscrits() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const [, params] = useRoute("/admin/sequences/:id/inscrits");
|
const [, params] = useRoute("/admin/sequences/:id/inscrits");
|
||||||
@@ -29,6 +41,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
const colors = {
|
const colors = {
|
||||||
directeur: "bg-blue-100 text-blue-800",
|
directeur: "bg-blue-100 text-blue-800",
|
||||||
chef_service: "bg-green-100 text-green-800",
|
chef_service: "bg-green-100 text-green-800",
|
||||||
|
tous: "bg-purple-100 text-purple-800",
|
||||||
autre: "bg-gray-100 text-gray-800",
|
autre: "bg-gray-100 text-gray-800",
|
||||||
};
|
};
|
||||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||||
@@ -38,6 +51,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
directeur: "Directeur",
|
directeur: "Directeur",
|
||||||
chef_service: "Chef de service",
|
chef_service: "Chef de service",
|
||||||
|
tous: "Tous managers",
|
||||||
autre: "Autre",
|
autre: "Autre",
|
||||||
};
|
};
|
||||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||||
@@ -52,7 +66,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
const [isAddInscritOpen, setIsAddInscritOpen] = useState(false);
|
const [isAddInscritOpen, setIsAddInscritOpen] = useState(false);
|
||||||
const [selectedApprenantId, setSelectedApprenantId] = useState<number | null>(null);
|
const [selectedApprenantId, setSelectedApprenantId] = useState<number | null>(null);
|
||||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
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 utils = trpc.useUtils();
|
||||||
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
|
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
|
||||||
@@ -168,8 +182,8 @@ export default function AdminSequenceInscrits() {
|
|||||||
exportFeuilleMutation.mutate({ sequenceId });
|
exportFeuilleMutation.mutate({ sequenceId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendEmails = (type: "teaser" | "rappel" | "rappel_j1") => {
|
const handleSendEmails = (type: EmailActionType) => {
|
||||||
const typeLabel = type === "teaser" ? "teaser" : type === "rappel" ? "de rappel J-7" : "de rappel J-1";
|
const typeLabel = EMAIL_ACTION_LABELS[type];
|
||||||
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) {
|
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) {
|
||||||
sendEmailsMutation.mutate({ sequenceId, type });
|
sendEmailsMutation.mutate({ sequenceId, type });
|
||||||
}
|
}
|
||||||
@@ -569,7 +583,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
<TableCell>{item.apprenant.prenom}</TableCell>
|
<TableCell>{item.apprenant.prenom}</TableCell>
|
||||||
<TableCell>{item.apprenant.email}</TableCell>
|
<TableCell>{item.apprenant.email}</TableCell>
|
||||||
<TableCell>{item.apprenant.codeEtablissement}</TableCell>
|
<TableCell>{item.apprenant.codeEtablissement}</TableCell>
|
||||||
<TableCell>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
|
<TableCell>{getFonctionLabel(item.apprenant.fonction ?? "")}</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
{formatDate(item.inscription.dateInscription)}
|
{formatDate(item.inscription.dateInscription)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -617,7 +631,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsAddInscritOpen(false);
|
setIsAddInscritOpen(false);
|
||||||
utils.inscriptions.getBySequence.invalidate({ sequenceId });
|
utils.inscriptions.listBySequence.invalidate({ sequenceId });
|
||||||
toast.success("Apprenant inscrit avec succès");
|
toast.success("Apprenant inscrit avec succès");
|
||||||
}}
|
}}
|
||||||
onCancel={() => setIsAddInscritOpen(false)}
|
onCancel={() => setIsAddInscritOpen(false)}
|
||||||
@@ -630,7 +644,7 @@ export default function AdminSequenceInscrits() {
|
|||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<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>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{loadingPreview ? (
|
{loadingPreview ? (
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export default function AdminSequences() {
|
|||||||
capaciteMax: parseInt(formData.capaciteMax),
|
capaciteMax: parseInt(formData.capaciteMax),
|
||||||
dateBlocage: convertLocalToUTC(formData.dateBlocage),
|
dateBlocage: convertLocalToUTC(formData.dateBlocage),
|
||||||
statut: formData.statut,
|
statut: formData.statut,
|
||||||
formateurId: formData.formateur ? parseInt(formData.formateur) : null,
|
formateurId: formData.formateur || null,
|
||||||
dates: formData.dates.map(d => ({
|
dates: formData.dates.map(d => ({
|
||||||
...d,
|
...d,
|
||||||
dateDebut: convertLocalToUTC(d.dateDebut),
|
dateDebut: convertLocalToUTC(d.dateDebut),
|
||||||
@@ -429,7 +429,7 @@ export default function AdminSequences() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
directeur: "Directeur",
|
directeur: "Directeur",
|
||||||
chef_service: "Chef de service",
|
chef_service: "Chef de service",
|
||||||
tous: "Tous",
|
tous: "Tous managers",
|
||||||
autre: "Autre",
|
autre: "Autre",
|
||||||
};
|
};
|
||||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||||
@@ -535,7 +535,7 @@ export default function AdminSequences() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="directeur">Directeur</SelectItem>
|
<SelectItem value="directeur">Directeur</SelectItem>
|
||||||
<SelectItem value="chef_service">Chef de service</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>
|
<SelectItem value="autre">Autre</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1029,7 +1029,7 @@ export default function AdminSequences() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="directeur">Directeur</SelectItem>
|
<SelectItem value="directeur">Directeur</SelectItem>
|
||||||
<SelectItem value="chef_service">Chef de service</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>
|
<SelectItem value="autre">Autre</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { fr } from "date-fns/locale";
|
|||||||
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
|
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { APP_LOGO, APP_TITLE } from "@/const";
|
import { APP_LOGO, APP_TITLE } from "@/const";
|
||||||
|
import { sortSequencesForRegistration } from "@shared/registration";
|
||||||
|
|
||||||
export default function Inscription() {
|
export default function Inscription() {
|
||||||
const [, params] = useRoute("/inscription/:lien");
|
const [, params] = useRoute("/inscription/:lien");
|
||||||
@@ -71,9 +72,10 @@ export default function Inscription() {
|
|||||||
"autre": "autre"
|
"autre": "autre"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) {
|
const cibleFonction = publicCibleMap[selectedSequence.publicCible];
|
||||||
|
if (cibleFonction && cibleFonction !== formData.fonction) {
|
||||||
const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" :
|
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" :
|
const fonctionLabel = formData.fonction === "directeur" ? "Directeur" :
|
||||||
formData.fonction === "chef_service" ? "Chef de service" : "Autre";
|
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(() => {
|
const sessionsAvecPlaces = useMemo(() => {
|
||||||
if (!sequences) return [];
|
return sortSequencesForRegistration(sequences ?? []);
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [sequences]);
|
}, [sequences]);
|
||||||
|
|
||||||
|
const selectedSequence = sessionsAvecPlaces.find((sequence) => sequence.id === selectedSequenceId);
|
||||||
|
|
||||||
const formatDate = (dateValue: Date | string) => {
|
const formatDate = (dateValue: Date | string) => {
|
||||||
if (!dateValue) return "N/A";
|
if (!dateValue) return "N/A";
|
||||||
|
|
||||||
@@ -259,9 +250,9 @@ export default function Inscription() {
|
|||||||
{/* Sessions disponibles */}
|
{/* Sessions disponibles */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sessions disponibles</CardTitle>
|
<CardTitle>1. Choisissez votre session</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum)
|
Les sessions avec des places disponibles sont affichées en premier.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -270,14 +261,13 @@ export default function Inscription() {
|
|||||||
{sessionsAvecPlaces.map((sequence) => (
|
{sessionsAvecPlaces.map((sequence) => (
|
||||||
<div
|
<div
|
||||||
key={sequence.id}
|
key={sequence.id}
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 transition-all ${
|
||||||
selectedSequenceId === sequence.id
|
selectedSequenceId === sequence.id
|
||||||
? "border-blue-500 bg-blue-50"
|
? "border-blue-600 bg-blue-50 ring-2 ring-blue-200 shadow-md"
|
||||||
: sequence.isBloquee
|
: 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"
|
: "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="flex items-start justify-between">
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
@@ -299,7 +289,7 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
@@ -326,6 +316,24 @@ export default function Inscription() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,13 +349,16 @@ export default function Inscription() {
|
|||||||
{selectedSequenceId && (
|
{selectedSequenceId && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Vos informations</CardTitle>
|
<CardTitle>2. Vos informations</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Complétez le formulaire pour finaliser votre inscription
|
Complétez le formulaire pour finaliser votre inscription
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nom">Nom *</Label>
|
<Label htmlFor="nom">Nom *</Label>
|
||||||
@@ -414,12 +425,12 @@ export default function Inscription() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full text-base font-semibold shadow-sm"
|
||||||
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
|
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
|
||||||
>
|
>
|
||||||
{inscriptionMutation.isPending || createApprenantMutation.isPending
|
{inscriptionMutation.isPending || createApprenantMutation.isPending
|
||||||
? "Inscription en cours..."
|
? "Inscription en cours..."
|
||||||
: "Confirmer mon inscription"}
|
: "Finaliser mon inscription"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
104
server/__tests__/registrationExperience.test.ts
Normal file
104
server/__tests__/registrationExperience.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateApprenantsExcelExport } from "../apprenantExportService";
|
||||||
|
import { sortSequencesForRegistration } from "../../shared/registration";
|
||||||
|
|
||||||
|
const inscriptionSource = readFileSync(
|
||||||
|
new URL("../../client/src/pages/Inscription.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const sequencesSource = readFileSync(
|
||||||
|
new URL("../../client/src/pages/AdminSequences.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("parcours d'inscription", () => {
|
||||||
|
it("place les sessions avec places disponibles avant les sessions complètes ou fermées", () => {
|
||||||
|
const now = new Date("2026-10-01T10:00:00.000Z");
|
||||||
|
const sorted = sortSequencesForRegistration(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nom: "Session fermée",
|
||||||
|
statut: "bloquee",
|
||||||
|
dateBlocage: null,
|
||||||
|
capaciteMax: 12,
|
||||||
|
nbInscrits: 0,
|
||||||
|
dates: [{ dateDebut: "2026-10-04T09:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nom: "Session complète",
|
||||||
|
statut: "ouverte",
|
||||||
|
dateBlocage: null,
|
||||||
|
capaciteMax: 12,
|
||||||
|
nbInscrits: 12,
|
||||||
|
dates: [{ dateDebut: "2026-10-03T09:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nom: "Session disponible tardive",
|
||||||
|
statut: "ouverte",
|
||||||
|
dateBlocage: null,
|
||||||
|
capaciteMax: 12,
|
||||||
|
nbInscrits: 3,
|
||||||
|
dates: [{ dateDebut: "2026-10-05T09:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
nom: "Session disponible prioritaire",
|
||||||
|
statut: "ouverte",
|
||||||
|
dateBlocage: null,
|
||||||
|
capaciteMax: 12,
|
||||||
|
nbInscrits: 2,
|
||||||
|
dates: [{ dateDebut: "2026-10-02T09:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sorted.map((sequence) => sequence.id)).toEqual([4, 3, 2, 1]);
|
||||||
|
expect(sorted[0]).toMatchObject({ isBloquee: false, placesRestantes: 10 });
|
||||||
|
expect(sorted[3]).toMatchObject({ isBloquee: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("présente un bouton explicite de sélection de session et le libellé Tous managers", () => {
|
||||||
|
expect(inscriptionSource).toContain("Choisir cette session");
|
||||||
|
expect(inscriptionSource).toContain("Session sélectionnée");
|
||||||
|
expect(inscriptionSource).toContain("Tous managers");
|
||||||
|
expect(sequencesSource).toContain("Tous managers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export Excel des apprenants", () => {
|
||||||
|
it("génère les colonnes nom, prénom et formation suivie", () => {
|
||||||
|
const buffer = generateApprenantsExcelExport([
|
||||||
|
{ nom: "Durand", prenom: "Alice", formationSuivie: "Manager de proximité" },
|
||||||
|
{ nom: "Martin", prenom: "Bruno", formationSuivie: "Gestion de projet | Communication" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||||
|
expect(workbook.SheetNames).toEqual(["Apprenants"]);
|
||||||
|
|
||||||
|
const rows = XLSX.utils.sheet_to_json<string[]>(workbook.Sheets.Apprenants, {
|
||||||
|
header: 1,
|
||||||
|
raw: false,
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([
|
||||||
|
["Nom", "Prénom", "Formation suivie"],
|
||||||
|
["Durand", "Alice", "Manager de proximité"],
|
||||||
|
["Martin", "Bruno", "Gestion de projet | Communication"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralise les valeurs interprétables comme formules Excel", () => {
|
||||||
|
const buffer = generateApprenantsExcelExport([
|
||||||
|
{ nom: "=HYPERLINK(\"https://example.org\")", prenom: "Alice", formationSuivie: "Formation test" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||||
|
const sheet = workbook.Sheets.Apprenants;
|
||||||
|
expect(sheet.A2?.v).toBe("'=HYPERLINK(\"https://example.org\")");
|
||||||
|
});
|
||||||
|
});
|
||||||
35
server/apprenantExportService.ts
Normal file
35
server/apprenantExportService.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
export type ApprenantExcelExport = {
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
formationSuivie: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empêche qu'une valeur saisie par un utilisateur soit interprétée comme une formule
|
||||||
|
* lorsqu'elle est ouverte dans Excel ou LibreOffice.
|
||||||
|
*/
|
||||||
|
function spreadsheetSafeText(value: string): string {
|
||||||
|
return /^[=+\-@]/.test(value) ? `'${value}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génère la liste des apprenants et des formations auxquelles ils sont inscrits. */
|
||||||
|
export function generateApprenantsExcelExport(apprenants: readonly ApprenantExcelExport[]): Buffer {
|
||||||
|
const rows = [
|
||||||
|
["Nom", "Prénom", "Formation suivie"],
|
||||||
|
...apprenants.map((apprenant) => [
|
||||||
|
spreadsheetSafeText(apprenant.nom),
|
||||||
|
spreadsheetSafeText(apprenant.prenom),
|
||||||
|
spreadsheetSafeText(apprenant.formationSuivie),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(rows);
|
||||||
|
worksheet["!cols"] = [{ wch: 24 }, { wch: 24 }, { wch: 48 }];
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Apprenants");
|
||||||
|
|
||||||
|
return Buffer.from(XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }));
|
||||||
|
}
|
||||||
51
server/db.ts
51
server/db.ts
@@ -381,6 +381,57 @@ export async function getApprenants() {
|
|||||||
return apprenantsWithInscriptions;
|
return apprenantsWithInscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une ligne par apprenant avec les formations non annulées auxquelles il est inscrit.
|
||||||
|
* La requête joint les tables en une fois afin d'éviter une requête par apprenant à l'export.
|
||||||
|
*/
|
||||||
|
export async function getApprenantsWithFormationNames() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: apprenants.id,
|
||||||
|
nom: apprenants.nom,
|
||||||
|
prenom: apprenants.prenom,
|
||||||
|
formationNom: formations.nom,
|
||||||
|
inscriptionStatut: inscriptions.statut,
|
||||||
|
})
|
||||||
|
.from(apprenants)
|
||||||
|
.leftJoin(inscriptions, eq(inscriptions.apprenantId, apprenants.id))
|
||||||
|
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||||
|
.leftJoin(formations, eq(sequences.formationId, formations.id));
|
||||||
|
|
||||||
|
const byApprenant = new Map<number, { nom: string; prenom: string; formations: Set<string> }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = byApprenant.get(row.id) ?? {
|
||||||
|
nom: row.nom,
|
||||||
|
prenom: row.prenom,
|
||||||
|
formations: new Set<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row.inscriptionStatut !== "annulee" && row.formationNom) {
|
||||||
|
entry.formations.add(row.formationNom);
|
||||||
|
}
|
||||||
|
byApprenant.set(row.id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byApprenant.values()]
|
||||||
|
.map((apprenant) => ({
|
||||||
|
nom: apprenant.nom,
|
||||||
|
prenom: apprenant.prenom,
|
||||||
|
formationSuivie: [...apprenant.formations]
|
||||||
|
.sort((left, right) => left.localeCompare(right, "fr", { sensitivity: "base" }))
|
||||||
|
.join(" | "),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const nomOrder = left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" });
|
||||||
|
return nomOrder !== 0
|
||||||
|
? nomOrder
|
||||||
|
: left.prenom.localeCompare(right.prenom, "fr", { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getApprenantsByFormateur(formateurId: number) {
|
export async function getApprenantsByFormateur(formateurId: number) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
|
|||||||
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
|
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
|
||||||
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
|
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
|
||||||
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
|
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
|
||||||
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
|
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre' },
|
||||||
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
|
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
|
|||||||
yPos += 6;
|
yPos += 6;
|
||||||
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
|
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
|
||||||
yPos += 6;
|
yPos += 6;
|
||||||
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
|
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos);
|
||||||
yPos += 8;
|
yPos += 8;
|
||||||
|
|
||||||
// Dates de formation
|
// Dates de formation
|
||||||
@@ -260,7 +260,7 @@ export function generateFeuillePresenceJour(
|
|||||||
const publicCibleLabel =
|
const publicCibleLabel =
|
||||||
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
|
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
|
||||||
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
|
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
|
||||||
feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre';
|
feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre';
|
||||||
|
|
||||||
const fonctionLabel = (f: string | null) =>
|
const fonctionLabel = (f: string | null) =>
|
||||||
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
|
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
|
||||||
@@ -434,7 +434,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fonction pour générer une page de feuille de présence pour une journée
|
// Fonction pour générer une page de feuille de présence pour une journée
|
||||||
const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
|
const generatePageForDate = (dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
|
||||||
if (!isFirstPage) {
|
if (!isFirstPage) {
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
}
|
}
|
||||||
@@ -464,7 +464,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
|||||||
yPos += 6;
|
yPos += 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
|
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos);
|
||||||
yPos += 8;
|
yPos += 8;
|
||||||
|
|
||||||
// Date de la journée
|
// Date de la journée
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
|
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
|
||||||
import { logNotification } from "./notificationLogsDb";
|
import { logNotification } from "./notificationLogsDb";
|
||||||
import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService";
|
import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService";
|
||||||
|
import { generateApprenantsExcelExport } from "./apprenantExportService";
|
||||||
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
|
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
@@ -326,6 +327,17 @@ export const appRouter = router({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
exportExcel: adminProcedure.mutation(async () => {
|
||||||
|
const apprenants = await db.getApprenantsWithFormationNames();
|
||||||
|
const buffer = generateApprenantsExcelExport(apprenants);
|
||||||
|
const exportDate = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: buffer.toString("base64"),
|
||||||
|
filename: `apprenants_${exportDate}.xlsx`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||||
const results = await db.getInscriptionsByApprenant(input.id);
|
const results = await db.getInscriptionsByApprenant(input.id);
|
||||||
// Récupérer les informations de formation et séquence pour chaque inscription
|
// Récupérer les informations de formation et séquence pour chaque inscription
|
||||||
|
|||||||
55
shared/registration.ts
Normal file
55
shared/registration.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Détermine l'ordre d'affichage des séquences sur le lien d'inscription public.
|
||||||
|
* Les sessions ouvertes avec des places disponibles sont toujours proposées en premier.
|
||||||
|
*/
|
||||||
|
export type RegistrationSequence = {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
statut: string;
|
||||||
|
dateBlocage?: Date | string | null;
|
||||||
|
capaciteMax: number;
|
||||||
|
nbInscrits?: number | null;
|
||||||
|
dates?: ReadonlyArray<{ dateDebut: Date | string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationSequenceWithAvailability<T extends RegistrationSequence> = T & {
|
||||||
|
isBloquee: boolean;
|
||||||
|
placesRestantes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstDateTimestamp(sequence: RegistrationSequence): number {
|
||||||
|
const firstDate = sequence.dates?.[0]?.dateDebut;
|
||||||
|
if (!firstDate) return Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
const timestamp = new Date(firstDate).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPriority(sequence: RegistrationSequenceWithAvailability<RegistrationSequence>): number {
|
||||||
|
if (sequence.isBloquee) return 2;
|
||||||
|
return sequence.placesRestantes > 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortSequencesForRegistration<T extends RegistrationSequence>(
|
||||||
|
sequences: readonly T[],
|
||||||
|
now = new Date(),
|
||||||
|
): RegistrationSequenceWithAvailability<T>[] {
|
||||||
|
return sequences
|
||||||
|
.map((sequence) => {
|
||||||
|
const dateBlocage = sequence.dateBlocage ? new Date(sequence.dateBlocage) : null;
|
||||||
|
const hasReachedBlockingDate = dateBlocage !== null && now >= dateBlocage;
|
||||||
|
const isBloquee = sequence.statut !== "ouverte" || hasReachedBlockingDate;
|
||||||
|
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits ?? 0));
|
||||||
|
|
||||||
|
return { ...sequence, isBloquee, placesRestantes };
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const priorityDifference = displayPriority(left) - displayPriority(right);
|
||||||
|
if (priorityDifference !== 0) return priorityDifference;
|
||||||
|
|
||||||
|
const dateDifference = firstDateTimestamp(left) - firstDateTimestamp(right);
|
||||||
|
if (dateDifference !== 0) return dateDifference;
|
||||||
|
|
||||||
|
return left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user