Files
formation-manager-itinova/client/src/pages/AdminCalendrier.tsx
Manus fc4df8ed36 Checkpoint: Correction des erreurs sur la page rapport-public-cible et harmonisation de la charte graphique des titres :
**Corrections d'erreurs :**

1. **Erreurs SQL DATE_FORMAT corrigées** (analyticsDb.ts) :
   - Remplacement des sous-requêtes corrélées par des JOIN dans getTauxRemplissageByMonth()
   - Utilisation de COUNT(CASE WHEN...) au lieu de SUM(SELECT COUNT(*))
   - Ajout de leftJoin pour éviter les erreurs de sous-requêtes

2. **Erreur React "Cannot read properties of null" corrigée** (AdminRapportPublicCible.tsx) :
   - Ajout de l'opérateur null-safe `?.` pour accéder à `insc.apprenant?.fonction`
   - Vérification explicite si la fonction est null/undefined
   - Gestion du cas "public cible = tous" qui correspond toujours

3. **Tests réussis** :
   - Page rapport-public-cible : affiche correctement les statistiques et recommandations
   - Tableau de bord analytique : graphiques et statistiques fonctionnels
   - Page suivi questionnaires : affiche correctement les données

**Harmonisation charte graphique :**

- Application du style bleu clair (#6B9FE8) avec ombre portée à tous les titres des 32 pages
- Création de la classe CSS `.page-title` réutilisable dans index.css
- Cohérence visuelle sur toute l'application

**Résultat :**
 Toutes les erreurs SQL et React sont corrigées
 Les pages fonctionnent correctement sans erreur
 Charte graphique harmonisée sur toutes les pages
2026-01-20 11:46:56 -05:00

536 lines
23 KiB
TypeScript

import { useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Filter, Download } from "lucide-react";
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";
type ViewMode = 'month' | 'week' | 'day';
export default function AdminCalendrier() {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedSequence, setSelectedSequence] = useState<any>(null);
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: 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 monthEnd = endOfMonth(currentDate);
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Obtenir le premier jour de la semaine (lundi = 0)
const firstDayOfWeek = (monthStart.getDay() + 6) % 7;
// Créer un tableau avec les jours vides au début
const calendarDays = [
...Array(firstDayOfWeek).fill(null),
...daysInMonth,
];
const getSequencesForDay = (day: Date) => {
if (!filteredSequences) return [];
return filteredSequences.filter((seq) => {
if (!seq.dates || seq.dates.length === 0) return false;
return seq.dates.some((dateForm: any) => {
// Gérer le cas où dateDebut est déjà un objet Date ou une chaîne
let dateDebut: Date;
if (typeof dateForm.dateDebut === 'string') {
dateDebut = parseISO(dateForm.dateDebut);
} else if (dateForm.dateDebut instanceof Date) {
dateDebut = dateForm.dateDebut;
} else {
return false;
}
return isSameDay(dateDebut, day);
});
});
};
const handlePrevious = () => {
if (viewMode === 'month') {
setCurrentDate(subMonths(currentDate, 1));
} else if (viewMode === 'week') {
setCurrentDate(subWeeks(currentDate, 1));
} else {
setCurrentDate(subDays(currentDate, 1));
}
};
const handleNext = () => {
if (viewMode === 'month') {
setCurrentDate(addMonths(currentDate, 1));
} else if (viewMode === 'week') {
setCurrentDate(addWeeks(currentDate, 1));
} else {
setCurrentDate(addDays(currentDate, 1));
}
};
const handleSequenceClick = (sequence: any) => {
setSelectedSequence(sequence);
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 labels: Record<string, string> = {
directeurs: "Directeurs",
chefs_service: "Chefs de service",
tous: "Tous",
autre: "Autre",
};
return labels[publicCible] || publicCible;
};
const getStatutBadge = (statut: string) => {
const badges: Record<string, string> = {
ouverte: "bg-green-100 text-green-800",
fermee: "bg-red-100 text-red-800",
annulee: "bg-gray-100 text-gray-800",
};
return badges[statut] || "bg-blue-100 text-blue-800";
};
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="page-title">Calendrier des séquences</h1>
<p className="text-muted-foreground">
Vue mensuelle de toutes les séquences planifiées
</p>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Calendrier des séquences
</CardTitle>
<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>
{/* Barre de filtres */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Filtres :</span>
</div>
<Select value={filterFormation} onValueChange={setFilterFormation}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Formation" />
</SelectTrigger>
<SelectContent>
<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>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Chargement...</div>
) : viewMode === 'month' ? (
<div className="grid grid-cols-7 gap-2">
{/* En-têtes des jours */}
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
<div
key={day}
className="text-center font-semibold text-sm text-muted-foreground py-2"
>
{day}
</div>
))}
{/* Jours du calendrier */}
{calendarDays.map((day, index) => {
if (!day) {
return <div key={`empty-${index}`} className="min-h-[100px]" />;
}
const daySequences = getSequencesForDay(day);
const isToday = isSameDay(day, new Date());
const dayKey = format(day, "yyyy-MM-dd");
return (
<div
key={dayKey}
className={`min-h-[100px] border rounded-lg p-2 ${
!isSameMonth(day, currentDate)
? "bg-muted/50"
: isToday
? "bg-blue-50 border-blue-300"
: "bg-background"
}`}
>
<div className="font-semibold text-sm mb-1">
{format(day, "d")}
</div>
<div className="space-y-1">
{daySequences.map((seq) => (
<button
key={seq.id}
onClick={() => handleSequenceClick(seq)}
className="w-full text-left text-xs p-1.5 rounded bg-blue-100 hover:bg-blue-200 text-blue-800 space-y-0.5"
>
<div className="font-medium truncate">{seq.nom}</div>
<div className="text-[10px] font-semibold opacity-90 truncate">{seq.formation?.nom || "Sans formation"}</div>
<div className="text-[10px] opacity-80 truncate">
{seq.lieu} {seq.formateur && `• ${seq.formateur}`}
</div>
<div className="text-[10px] opacity-80">
{seq.nbInscrits || 0}/{seq.capaciteMax} inscrits
</div>
</button>
))}
</div>
</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>
</Card>
{/* Dialog de détails */}
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Détails de la séquence</DialogTitle>
<DialogDescription>
Informations complètes sur la séquence sélectionnée
</DialogDescription>
</DialogHeader>
{selectedSequence && (
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg">
{selectedSequence.formation?.nom || "Sans titre"}
</h3>
<p className="text-sm text-muted-foreground">
{selectedSequence.formation?.description || "Aucune description"}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
<p className="text-sm">{selectedSequence.lieu}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{getPublicCibleLabel(selectedSequence.publicCible)}
</span>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
<p className="text-sm">
{selectedSequence.nbInscrits || 0} / {selectedSequence.capaciteMax}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Statut</p>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(selectedSequence.statut)}`}>
{selectedSequence.statut}
</span>
</div>
{selectedSequence.formateur && (
<div>
<p className="text-sm font-medium text-muted-foreground">Formateur</p>
<p className="text-sm">
{typeof selectedSequence.formateur === 'object' && selectedSequence.formateur !== null
? selectedSequence.formateur.nom || String(selectedSequence.formateur)
: selectedSequence.formateur
}
</p>
</div>
)}
{selectedSequence.dateBloquage && (
<div>
<p className="text-sm font-medium text-muted-foreground">Date de blocage</p>
<p className="text-sm">
{typeof selectedSequence.dateBloquage === 'string'
? format(parseISO(selectedSequence.dateBloquage), "dd/MM/yyyy", { locale: fr })
: format(selectedSequence.dateBloquage, "dd/MM/yyyy", { locale: fr })
}
</p>
</div>
)}
</div>
{selectedSequence.dates && selectedSequence.dates.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
<div className="space-y-1">
{selectedSequence.dates.map((dateForm: any, index: number) => {
const dateDebut = typeof dateForm.dateDebut === 'string' ? parseISO(dateForm.dateDebut) : dateForm.dateDebut;
const dateFin = typeof dateForm.dateFin === 'string' ? parseISO(dateForm.dateFin) : dateForm.dateFin;
const dateKey = `${selectedSequence.id}-date-${index}`;
return (
<div key={dateKey} className="text-sm bg-muted p-2 rounded">
Du {format(dateDebut, "dd/MM/yyyy", { locale: fr })} au{" "}
{format(dateFin, "dd/MM/yyyy", { locale: fr })}
</div>
);
})}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}