Checkpoint: Améliorations majeures du calendrier : ajout de filtres (formation, lieu, public cible), vues alternatives (semaine, jour) et exports (PDF, iCal)
This commit is contained in:
@@ -9,17 +9,39 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Filter, Download } from "lucide-react";
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, parseISO, addMonths, subMonths } from "date-fns";
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, parseISO, addMonths, subMonths, startOfWeek, endOfWeek, addWeeks, subWeeks, addDays, subDays } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
type ViewMode = 'month' | 'week' | 'day';
|
||||||
|
|
||||||
export default function AdminCalendrier() {
|
export default function AdminCalendrier() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [selectedSequence, setSelectedSequence] = useState<any>(null);
|
const [selectedSequence, setSelectedSequence] = useState<any>(null);
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('month');
|
||||||
|
const [filterFormation, setFilterFormation] = useState<string>('all');
|
||||||
|
const [filterLieu, setFilterLieu] = useState<string>('all');
|
||||||
|
const [filterPublicCible, setFilterPublicCible] = useState<string>('all');
|
||||||
|
|
||||||
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
|
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
|
||||||
|
const { data: formations } = trpc.formations.list.useQuery();
|
||||||
|
|
||||||
|
// Filtrer les séquences selon les critères
|
||||||
|
const filteredSequences = sequences?.filter((seq) => {
|
||||||
|
if (filterFormation !== 'all' && seq.formationId !== parseInt(filterFormation)) return false;
|
||||||
|
if (filterLieu !== 'all' && seq.lieu !== filterLieu) return false;
|
||||||
|
if (filterPublicCible !== 'all' && seq.publicCible !== filterPublicCible) return false;
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// Extraire les lieux uniques pour le filtre
|
||||||
|
const uniqueLieux = Array.from(new Set(sequences?.map(s => s.lieu).filter(Boolean))) as string[];
|
||||||
|
|
||||||
|
// Extraire les publics cibles uniques
|
||||||
|
const uniquePublicsCibles = Array.from(new Set(sequences?.map(s => s.publicCible).filter(Boolean))) as string[];
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentDate);
|
const monthStart = startOfMonth(currentDate);
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const monthEnd = endOfMonth(currentDate);
|
||||||
@@ -35,8 +57,8 @@ export default function AdminCalendrier() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const getSequencesForDay = (day: Date) => {
|
const getSequencesForDay = (day: Date) => {
|
||||||
if (!sequences) return [];
|
if (!filteredSequences) return [];
|
||||||
return sequences.filter((seq) => {
|
return filteredSequences.filter((seq) => {
|
||||||
if (!seq.dates || seq.dates.length === 0) return false;
|
if (!seq.dates || seq.dates.length === 0) return false;
|
||||||
return seq.dates.some((dateForm: any) => {
|
return seq.dates.some((dateForm: any) => {
|
||||||
// Gérer le cas où dateDebut est déjà un objet Date ou une chaîne
|
// Gérer le cas où dateDebut est déjà un objet Date ou une chaîne
|
||||||
@@ -53,12 +75,24 @@ export default function AdminCalendrier() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviousMonth = () => {
|
const handlePrevious = () => {
|
||||||
setCurrentDate(subMonths(currentDate, 1));
|
if (viewMode === 'month') {
|
||||||
|
setCurrentDate(subMonths(currentDate, 1));
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
setCurrentDate(subWeeks(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(subDays(currentDate, 1));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextMonth = () => {
|
const handleNext = () => {
|
||||||
setCurrentDate(addMonths(currentDate, 1));
|
if (viewMode === 'month') {
|
||||||
|
setCurrentDate(addMonths(currentDate, 1));
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
setCurrentDate(addWeeks(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(addDays(currentDate, 1));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSequenceClick = (sequence: any) => {
|
const handleSequenceClick = (sequence: any) => {
|
||||||
@@ -66,6 +100,56 @@ export default function AdminCalendrier() {
|
|||||||
setIsDetailOpen(true);
|
setIsDetailOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportICal = () => {
|
||||||
|
const icalContent = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Formation Manager Itinova//FR',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'X-WR-CALNAME:Calendrier des formations',
|
||||||
|
'X-WR-TIMEZONE:Europe/Paris',
|
||||||
|
];
|
||||||
|
|
||||||
|
filteredSequences.forEach((seq) => {
|
||||||
|
if (seq.dates && seq.dates.length > 0) {
|
||||||
|
seq.dates.forEach((dateForm: any) => {
|
||||||
|
const dateDebut = typeof dateForm.dateDebut === 'string' ? parseISO(dateForm.dateDebut) : dateForm.dateDebut;
|
||||||
|
const dateFin = typeof dateForm.dateFin === 'string' ? parseISO(dateForm.dateFin) : dateForm.dateFin;
|
||||||
|
|
||||||
|
const dtstart = format(dateDebut, "yyyyMMdd'T'HHmmss");
|
||||||
|
const dtend = format(dateFin, "yyyyMMdd'T'HHmmss");
|
||||||
|
|
||||||
|
icalContent.push(
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${seq.id}-${dateForm.id || Math.random()}@formation-manager.itinova`,
|
||||||
|
`DTSTAMP:${format(new Date(), "yyyyMMdd'T'HHmmss'Z'")}`,
|
||||||
|
`DTSTART:${dtstart}`,
|
||||||
|
`DTEND:${dtend}`,
|
||||||
|
`SUMMARY:${seq.nom}`,
|
||||||
|
`DESCRIPTION:${seq.formation?.nom || 'Formation'} - ${seq.nbInscrits || 0}/${seq.capaciteMax} inscrits`,
|
||||||
|
`LOCATION:${seq.lieu}`,
|
||||||
|
'STATUS:CONFIRMED',
|
||||||
|
'SEQUENCE:0',
|
||||||
|
'END:VEVENT'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
icalContent.push('END:VCALENDAR');
|
||||||
|
|
||||||
|
const blob = new Blob([icalContent.join('\r\n')], { type: 'text/calendar;charset=utf-8' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `calendrier-formations-${format(currentDate, "yyyy-MM")}.ics`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
const getPublicCibleLabel = (publicCible: string) => {
|
const getPublicCibleLabel = (publicCible: string) => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
directeurs: "Directeurs",
|
directeurs: "Directeurs",
|
||||||
@@ -95,38 +179,131 @@ export default function AdminCalendrier() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div>
|
||||||
<CalendarIcon className="h-5 w-5" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{format(currentDate, "MMMM yyyy", { locale: fr })}
|
<CalendarIcon className="h-5 w-5" />
|
||||||
</CardTitle>
|
Calendrier des séquences
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Cliquez sur une séquence pour voir les détails
|
<CardDescription>
|
||||||
</CardDescription>
|
Cliquez sur une séquence pour voir les détails
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>
|
||||||
|
<SelectTrigger className="w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="month">Mois</SelectItem>
|
||||||
|
<SelectItem value="week">Semaine</SelectItem>
|
||||||
|
<SelectItem value="day">Jour</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="icon" onClick={handlePreviousMonth}>
|
{/* Barre de filtres */}
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
</Button>
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
variant="outline"
|
<span className="text-sm font-medium">Filtres :</span>
|
||||||
onClick={() => setCurrentDate(new Date())}
|
</div>
|
||||||
>
|
<Select value={filterFormation} onValueChange={setFilterFormation}>
|
||||||
Aujourd'hui
|
<SelectTrigger className="w-[200px]">
|
||||||
</Button>
|
<SelectValue placeholder="Formation" />
|
||||||
<Button variant="outline" size="icon" onClick={handleNextMonth}>
|
</SelectTrigger>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<SelectContent>
|
||||||
</Button>
|
<SelectItem value="all">Toutes les formations</SelectItem>
|
||||||
|
{formations?.map((formation) => (
|
||||||
|
<SelectItem key={formation.id} value={formation.id.toString()}>
|
||||||
|
{formation.nom}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterLieu} onValueChange={setFilterLieu}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Lieu" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les lieux</SelectItem>
|
||||||
|
{uniqueLieux.map((lieu) => (
|
||||||
|
<SelectItem key={lieu} value={lieu}>
|
||||||
|
{lieu}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterPublicCible} onValueChange={setFilterPublicCible}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Public cible" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les publics</SelectItem>
|
||||||
|
{uniquePublicsCibles.map((pc) => (
|
||||||
|
<SelectItem key={pc} value={pc}>
|
||||||
|
{getPublicCibleLabel(pc)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(filterFormation !== 'all' || filterLieu !== 'all' || filterPublicCible !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setFilterFormation('all');
|
||||||
|
setFilterLieu('all');
|
||||||
|
setFilterPublicCible('all');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation et exports */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{viewMode === 'month' && format(currentDate, "MMMM yyyy", { locale: fr })}
|
||||||
|
{viewMode === 'week' && `Semaine du ${format(startOfWeek(currentDate, { locale: fr }), "d MMM", { locale: fr })}`}
|
||||||
|
{viewMode === 'day' && format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportPDF}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportICal}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
iCal
|
||||||
|
</Button>
|
||||||
|
<div className="border-l mx-1" />
|
||||||
|
<Button variant="outline" size="icon" onClick={handlePrevious}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentDate(new Date())}
|
||||||
|
>
|
||||||
|
Aujourd'hui
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleNext}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">Chargement...</div>
|
<div className="text-center py-8">Chargement...</div>
|
||||||
) : (
|
) : viewMode === 'month' ? (
|
||||||
<div className="grid grid-cols-7 gap-2">
|
<div className="grid grid-cols-7 gap-2">
|
||||||
{/* En-têtes des jours */}
|
{/* En-têtes des jours */}
|
||||||
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
|
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
|
||||||
@@ -184,6 +361,81 @@ export default function AdminCalendrier() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === 'week' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-8 gap-2">
|
||||||
|
<div className="text-center font-semibold text-sm text-muted-foreground py-2">Heure</div>
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => {
|
||||||
|
const day = addDays(startOfWeek(currentDate, { locale: fr }), i);
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-center">
|
||||||
|
<div className="font-semibold text-sm">{format(day, "EEE", { locale: fr })}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{format(day, "d MMM", { locale: fr })}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
Vue hebdomadaire - Affichage des séquences par jour
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => {
|
||||||
|
const day = addDays(startOfWeek(currentDate, { locale: fr }), i);
|
||||||
|
const daySequences = getSequencesForDay(day);
|
||||||
|
return daySequences.length > 0 ? (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">{format(day, "EEEE d MMMM", { locale: fr })}</h4>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{daySequences.map((seq) => (
|
||||||
|
<button
|
||||||
|
key={seq.id}
|
||||||
|
onClick={() => handleSequenceClick(seq)}
|
||||||
|
className="w-full text-left p-3 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{seq.nom}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{seq.formation?.nom || "Sans formation"}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{seq.lieu} • {seq.nbInscrits || 0}/{seq.capaciteMax} inscrits
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold">{format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}</h3>
|
||||||
|
</div>
|
||||||
|
{getSequencesForDay(currentDate).length > 0 ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{getSequencesForDay(currentDate).map((seq) => (
|
||||||
|
<button
|
||||||
|
key={seq.id}
|
||||||
|
onClick={() => handleSequenceClick(seq)}
|
||||||
|
className="w-full text-left p-4 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-lg">{seq.nom}</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">{seq.formation?.nom || "Sans formation"}</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||||
|
<span>📍 {seq.lieu}</span>
|
||||||
|
<span>👥 {seq.nbInscrits || 0}/{seq.capaciteMax} inscrits</span>
|
||||||
|
{seq.formateur && <span>👨🏫 {typeof seq.formateur === 'object' ? seq.formateur.nom : seq.formateur}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
Aucune séquence prévue pour ce jour
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
22
todo.md
22
todo.md
@@ -602,3 +602,25 @@
|
|||||||
- [x] Identifier la cause de l'erreur lors du survol des vignettes
|
- [x] Identifier la cause de l'erreur lors du survol des vignettes
|
||||||
- [x] Corriger le code pour résoudre l'erreur
|
- [x] Corriger le code pour résoudre l'erreur
|
||||||
- [x] Tester le calendrier après correction
|
- [x] Tester le calendrier après correction
|
||||||
|
|
||||||
|
## Améliorations du calendrier
|
||||||
|
|
||||||
|
### Filtres du calendrier
|
||||||
|
- [x] Ajouter un filtre par formation
|
||||||
|
- [x] Ajouter un filtre par lieu
|
||||||
|
- [x] Ajouter un filtre par public cible
|
||||||
|
- [x] Implémenter la logique de filtrage dans la vue calendrier
|
||||||
|
- [x] Tester les filtres avec différentes combinaisons
|
||||||
|
|
||||||
|
### Vues alternatives du calendrier
|
||||||
|
- [x] Créer la vue hebdomadaire
|
||||||
|
- [x] Créer la vue journalière
|
||||||
|
- [x] Ajouter un sélecteur de vue (mois/semaine/jour)
|
||||||
|
- [x] Adapter l'affichage des séquences pour chaque vue
|
||||||
|
- [x] Tester la navigation entre les vues
|
||||||
|
|
||||||
|
### Export du calendrier
|
||||||
|
- [x] Implémenter l'export PDF du calendrier mensuel
|
||||||
|
- [x] Implémenter l'export iCal pour partage avec les formateurs
|
||||||
|
- [x] Ajouter les boutons d'export dans l'interface
|
||||||
|
- [x] Tester les exports avec différents mois
|
||||||
|
|||||||
Reference in New Issue
Block a user