566 lines
25 KiB
TypeScript
Executable File
566 lines
25 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card } from 'primereact/card';
|
|
import { Button } from 'primereact/button';
|
|
import { Calendar } from 'primereact/calendar';
|
|
import { Dropdown } from 'primereact/dropdown';
|
|
import { Toolbar } from 'primereact/toolbar';
|
|
import { Badge } from 'primereact/badge';
|
|
import { Tag } from 'primereact/tag';
|
|
import { Dialog } from 'primereact/dialog';
|
|
import { DataTable } from 'primereact/datatable';
|
|
import { Column } from 'primereact/column';
|
|
import { useRouter } from 'next/navigation';
|
|
import { apiClient } from '../../../../services/api-client';
|
|
|
|
interface EvenementMaintenance {
|
|
id: number;
|
|
title: string;
|
|
start: Date;
|
|
end?: Date;
|
|
type: 'PREVENTIVE' | 'CORRECTIVE' | 'PLANIFIEE' | 'URGENTE';
|
|
statut: 'PLANIFIEE' | 'EN_COURS' | 'TERMINEE' | 'ANNULEE';
|
|
priorite: 'BASSE' | 'NORMALE' | 'HAUTE' | 'CRITIQUE';
|
|
materielNom: string;
|
|
technicienNom?: string;
|
|
description: string;
|
|
dureeEstimee: number;
|
|
color: string;
|
|
}
|
|
|
|
interface VueCalendrier {
|
|
date: Date;
|
|
evenements: EvenementMaintenance[];
|
|
conflits: Array<{
|
|
technicienId: number;
|
|
technicienNom: string;
|
|
maintenances: EvenementMaintenance[];
|
|
}>;
|
|
}
|
|
|
|
const CalendrierMaintenancePage = () => {
|
|
const [vueCalendrier, setVueCalendrier] = useState<VueCalendrier>({
|
|
date: new Date(),
|
|
evenements: [],
|
|
conflits: []
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
const [filterType, setFilterType] = useState<string | null>(null);
|
|
const [filterTechnicien, setFilterTechnicien] = useState<string | null>(null);
|
|
const [detailDialog, setDetailDialog] = useState(false);
|
|
const [selectedEvenement, setSelectedEvenement] = useState<EvenementMaintenance | null>(null);
|
|
const [techniciens, setTechniciens] = useState<any[]>([]);
|
|
const [vueMode, setVueMode] = useState<'mois' | 'semaine' | 'jour'>('mois');
|
|
const router = useRouter();
|
|
|
|
const typeOptions = [
|
|
{ label: 'Tous', value: null },
|
|
{ label: 'Préventive', value: 'PREVENTIVE' },
|
|
{ label: 'Corrective', value: 'CORRECTIVE' },
|
|
{ label: 'Planifiée', value: 'PLANIFIEE' },
|
|
{ label: 'Urgente', value: 'URGENTE' }
|
|
];
|
|
|
|
const vueModeOptions = [
|
|
{ label: 'Mois', value: 'mois' },
|
|
{ label: 'Semaine', value: 'semaine' },
|
|
{ label: 'Jour', value: 'jour' }
|
|
];
|
|
|
|
useEffect(() => {
|
|
loadCalendrierMaintenance();
|
|
loadTechniciens();
|
|
}, [selectedDate, filterType, filterTechnicien, vueMode]);
|
|
|
|
const loadCalendrierMaintenance = async () => {
|
|
try {
|
|
setLoading(true);
|
|
console.log('🔄 Chargement du calendrier de maintenance...');
|
|
|
|
const params = new URLSearchParams();
|
|
params.append('date', selectedDate.toISOString().split('T')[0]);
|
|
params.append('vue', vueMode);
|
|
if (filterType) params.append('type', filterType);
|
|
if (filterTechnicien) params.append('technicienId', filterTechnicien);
|
|
|
|
const response = await apiClient.get(`/api/maintenances/calendrier?${params.toString()}`);
|
|
console.log('✅ Calendrier chargé:', response.data);
|
|
|
|
// Transformer les données pour le calendrier
|
|
const evenements = response.data.maintenances?.map((m: any) => ({
|
|
...m,
|
|
start: new Date(m.datePlanifiee),
|
|
end: m.dateFin ? new Date(m.dateFin) : new Date(new Date(m.datePlanifiee).getTime() + m.dureeEstimee * 60 * 60 * 1000),
|
|
title: `${m.materielNom} - ${m.typeMaintenance}`,
|
|
color: getTypeColor(m.typeMaintenance, m.statut)
|
|
})) || [];
|
|
|
|
setVueCalendrier({
|
|
date: selectedDate,
|
|
evenements,
|
|
conflits: response.data.conflits || []
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ Erreur lors du chargement du calendrier:', error);
|
|
setVueCalendrier({ date: selectedDate, evenements: [], conflits: [] });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadTechniciens = async () => {
|
|
try {
|
|
const response = await apiClient.get('/api/employes/techniciens');
|
|
setTechniciens(response.data || []);
|
|
} catch (error) {
|
|
console.error('❌ Erreur lors du chargement des techniciens:', error);
|
|
}
|
|
};
|
|
|
|
const getTypeColor = (type: string, statut: string) => {
|
|
if (statut === 'TERMINEE') return '#10b981'; // vert
|
|
if (statut === 'ANNULEE') return '#6b7280'; // gris
|
|
|
|
switch (type) {
|
|
case 'PREVENTIVE': return '#3b82f6'; // bleu
|
|
case 'CORRECTIVE': return '#f59e0b'; // orange
|
|
case 'PLANIFIEE': return '#8b5cf6'; // violet
|
|
case 'URGENTE': return '#ef4444'; // rouge
|
|
default: return '#6b7280';
|
|
}
|
|
};
|
|
|
|
const getStatutSeverity = (statut: string) => {
|
|
switch (statut) {
|
|
case 'PLANIFIEE': return 'info';
|
|
case 'EN_COURS': return 'warning';
|
|
case 'TERMINEE': return 'success';
|
|
case 'ANNULEE': return 'danger';
|
|
default: return 'secondary';
|
|
}
|
|
};
|
|
|
|
const getTypeSeverity = (type: string) => {
|
|
switch (type) {
|
|
case 'PREVENTIVE': return 'info';
|
|
case 'CORRECTIVE': return 'warning';
|
|
case 'PLANIFIEE': return 'success';
|
|
case 'URGENTE': return 'danger';
|
|
default: return 'secondary';
|
|
}
|
|
};
|
|
|
|
const ouvrirDetail = (evenement: EvenementMaintenance) => {
|
|
setSelectedEvenement(evenement);
|
|
setDetailDialog(true);
|
|
};
|
|
|
|
const planifierMaintenance = () => {
|
|
router.push(`/maintenance/nouveau?date=${selectedDate.toISOString().split('T')[0]}`);
|
|
};
|
|
|
|
const resoudreConflit = async (conflitId: number) => {
|
|
try {
|
|
await apiClient.post(`/api/maintenances/conflits/${conflitId}/resoudre`);
|
|
console.log('✅ Conflit résolu');
|
|
loadCalendrierMaintenance();
|
|
} catch (error) {
|
|
console.error('❌ Erreur lors de la résolution du conflit:', error);
|
|
}
|
|
};
|
|
|
|
const genererJoursCalendrier = () => {
|
|
const debut = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
|
|
const fin = new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, 0);
|
|
const jours = [];
|
|
|
|
// Ajouter les jours du mois précédent pour compléter la première semaine
|
|
const premierJourSemaine = debut.getDay();
|
|
for (let i = premierJourSemaine - 1; i >= 0; i--) {
|
|
const jour = new Date(debut);
|
|
jour.setDate(jour.getDate() - i - 1);
|
|
jours.push(jour);
|
|
}
|
|
|
|
// Ajouter tous les jours du mois
|
|
for (let jour = 1; jour <= fin.getDate(); jour++) {
|
|
jours.push(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), jour));
|
|
}
|
|
|
|
// Ajouter les jours du mois suivant pour compléter la dernière semaine
|
|
const dernierJourSemaine = fin.getDay();
|
|
for (let i = 1; i <= 6 - dernierJourSemaine; i++) {
|
|
const jour = new Date(fin);
|
|
jour.setDate(jour.getDate() + i);
|
|
jours.push(jour);
|
|
}
|
|
|
|
return jours;
|
|
};
|
|
|
|
const getEvenementsJour = (jour: Date) => {
|
|
return vueCalendrier.evenements.filter(evt => {
|
|
const dateEvt = new Date(evt.start);
|
|
return dateEvt.toDateString() === jour.toDateString();
|
|
});
|
|
};
|
|
|
|
const leftToolbarTemplate = () => {
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
label="Retour Maintenance"
|
|
icon="pi pi-arrow-left"
|
|
className="p-button-outlined"
|
|
onClick={() => router.push('/maintenance')}
|
|
/>
|
|
<Button
|
|
label="Nouvelle Maintenance"
|
|
icon="pi pi-plus"
|
|
className="p-button-success"
|
|
onClick={planifierMaintenance}
|
|
/>
|
|
<Button
|
|
label="Optimiser Planning"
|
|
icon="pi pi-cog"
|
|
className="p-button-info"
|
|
onClick={() => router.push('/maintenance/optimiser-planning')}
|
|
/>
|
|
<Button
|
|
label="Exporter"
|
|
icon="pi pi-download"
|
|
className="p-button-secondary"
|
|
onClick={() => router.push('/maintenance/export-calendrier')}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const rightToolbarTemplate = () => {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Dropdown
|
|
value={vueMode}
|
|
options={vueModeOptions}
|
|
onChange={(e) => setVueMode(e.value)}
|
|
placeholder="Vue"
|
|
/>
|
|
<Button
|
|
icon="pi pi-chevron-left"
|
|
className="p-button-outlined"
|
|
onClick={() => {
|
|
const nouvellDate = new Date(selectedDate);
|
|
if (vueMode === 'mois') {
|
|
nouvellDate.setMonth(nouvellDate.getMonth() - 1);
|
|
} else if (vueMode === 'semaine') {
|
|
nouvellDate.setDate(nouvellDate.getDate() - 7);
|
|
} else {
|
|
nouvellDate.setDate(nouvellDate.getDate() - 1);
|
|
}
|
|
setSelectedDate(nouvellDate);
|
|
}}
|
|
/>
|
|
<Button
|
|
label="Aujourd'hui"
|
|
className="p-button-outlined"
|
|
onClick={() => setSelectedDate(new Date())}
|
|
/>
|
|
<Button
|
|
icon="pi pi-chevron-right"
|
|
className="p-button-outlined"
|
|
onClick={() => {
|
|
const nouvellDate = new Date(selectedDate);
|
|
if (vueMode === 'mois') {
|
|
nouvellDate.setMonth(nouvellDate.getMonth() + 1);
|
|
} else if (vueMode === 'semaine') {
|
|
nouvellDate.setDate(nouvellDate.getDate() + 7);
|
|
} else {
|
|
nouvellDate.setDate(nouvellDate.getDate() + 1);
|
|
}
|
|
setSelectedDate(nouvellDate);
|
|
}}
|
|
/>
|
|
<Button
|
|
icon="pi pi-refresh"
|
|
className="p-button-outlined"
|
|
onClick={loadCalendrierMaintenance}
|
|
tooltip="Actualiser"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const conflitBodyTemplate = (rowData: any) => {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
label="Résoudre"
|
|
icon="pi pi-check"
|
|
className="p-button-success p-button-sm"
|
|
onClick={() => resoudreConflit(rowData.id)}
|
|
/>
|
|
<Button
|
|
label="Reporter"
|
|
icon="pi pi-calendar"
|
|
className="p-button-warning p-button-sm"
|
|
onClick={() => router.push(`/maintenance/reporter/${rowData.id}`)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="grid">
|
|
<div className="col-12">
|
|
<Toolbar
|
|
className="mb-4"
|
|
left={leftToolbarTemplate}
|
|
right={rightToolbarTemplate}
|
|
/>
|
|
</div>
|
|
|
|
{/* Filtres */}
|
|
<div className="col-12">
|
|
<Card className="mb-4">
|
|
<div className="grid">
|
|
<div className="col-12 md:col-3">
|
|
<label className="font-medium mb-2 block">Type de maintenance</label>
|
|
<Dropdown
|
|
value={filterType}
|
|
options={typeOptions}
|
|
onChange={(e) => setFilterType(e.value)}
|
|
placeholder="Tous les types"
|
|
/>
|
|
</div>
|
|
<div className="col-12 md:col-3">
|
|
<label className="font-medium mb-2 block">Technicien</label>
|
|
<Dropdown
|
|
value={filterTechnicien}
|
|
options={techniciens}
|
|
onChange={(e) => setFilterTechnicien(e.value)}
|
|
optionLabel="nom"
|
|
optionValue="id"
|
|
placeholder="Tous les techniciens"
|
|
filter
|
|
/>
|
|
</div>
|
|
<div className="col-12 md:col-3">
|
|
<label className="font-medium mb-2 block">Date</label>
|
|
<Calendar
|
|
value={selectedDate}
|
|
onChange={(e) => setSelectedDate(e.value as Date)}
|
|
showIcon
|
|
dateFormat="dd/mm/yy"
|
|
placeholder="Sélectionner une date"
|
|
/>
|
|
</div>
|
|
<div className="col-12 md:col-3">
|
|
<label className="font-medium mb-2 block">Actions</label>
|
|
<Button
|
|
label="Réinitialiser"
|
|
icon="pi pi-filter-slash"
|
|
className="p-button-outlined w-full"
|
|
onClick={() => {
|
|
setFilterType(null);
|
|
setFilterTechnicien(null);
|
|
setSelectedDate(new Date());
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Calendrier */}
|
|
<div className="col-12 lg:col-8">
|
|
<Card title={`Calendrier de Maintenance - ${selectedDate.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })}`}>
|
|
{vueMode === 'mois' && (
|
|
<div className="grid">
|
|
{/* En-têtes des jours */}
|
|
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(jour => (
|
|
<div key={jour} className="col-12 md:col text-center font-medium p-2 bg-gray-100">
|
|
{jour}
|
|
</div>
|
|
))}
|
|
|
|
{/* Jours du calendrier */}
|
|
{genererJoursCalendrier().map((jour, index) => {
|
|
const evenementsJour = getEvenementsJour(jour);
|
|
const estAujourdhui = jour.toDateString() === new Date().toDateString();
|
|
const estMoisCourant = jour.getMonth() === selectedDate.getMonth();
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`col-12 md:col border-1 border-gray-200 p-2 min-h-6rem cursor-pointer
|
|
${estAujourdhui ? 'bg-blue-50' : ''}
|
|
${!estMoisCourant ? 'text-gray-400' : ''}
|
|
`}
|
|
onClick={() => setSelectedDate(jour)}
|
|
>
|
|
<div className={`font-medium mb-1 ${estAujourdhui ? 'text-blue-600' : ''}`}>
|
|
{jour.getDate()}
|
|
</div>
|
|
<div className="flex flex-column gap-1">
|
|
{evenementsJour.slice(0, 3).map(evt => (
|
|
<div
|
|
key={evt.id}
|
|
className="text-xs p-1 border-round cursor-pointer"
|
|
style={{ backgroundColor: evt.color, color: 'white' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
ouvrirDetail(evt);
|
|
}}
|
|
>
|
|
{evt.title.length > 20 ? `${evt.title.substring(0, 20)}...` : evt.title}
|
|
</div>
|
|
))}
|
|
{evenementsJour.length > 3 && (
|
|
<div className="text-xs text-500">
|
|
+{evenementsJour.length - 3} autres
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{vueMode === 'jour' && (
|
|
<div>
|
|
<h3>{selectedDate.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</h3>
|
|
<div className="flex flex-column gap-2">
|
|
{getEvenementsJour(selectedDate).map(evt => (
|
|
<div
|
|
key={evt.id}
|
|
className="p-3 border-round cursor-pointer"
|
|
style={{ backgroundColor: evt.color + '20', borderLeft: `4px solid ${evt.color}` }}
|
|
onClick={() => ouvrirDetail(evt)}
|
|
>
|
|
<div className="flex justify-content-between align-items-center">
|
|
<div>
|
|
<div className="font-medium">{evt.title}</div>
|
|
<div className="text-sm text-500">{evt.description}</div>
|
|
<div className="text-xs text-500 mt-1">
|
|
{evt.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
|
{evt.end && ` - ${evt.end.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-column gap-1">
|
|
<Tag value={evt.type} severity={getTypeSeverity(evt.type)} />
|
|
<Tag value={evt.statut} severity={getStatutSeverity(evt.statut)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{getEvenementsJour(selectedDate).length === 0 && (
|
|
<div className="text-center text-500 p-4">
|
|
Aucune maintenance planifiée pour cette date
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Panneau latéral */}
|
|
<div className="col-12 lg:col-4">
|
|
{/* Conflits */}
|
|
{vueCalendrier.conflits.length > 0 && (
|
|
<Card title="⚠️ Conflits de Planning" className="mb-4">
|
|
<DataTable value={vueCalendrier.conflits} responsiveLayout="scroll">
|
|
<Column field="technicienNom" header="Technicien" />
|
|
<Column
|
|
field="maintenances"
|
|
header="Conflits"
|
|
body={(rowData) => <Badge value={rowData.maintenances.length} severity="danger" />}
|
|
/>
|
|
<Column body={conflitBodyTemplate} header="Actions" />
|
|
</DataTable>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Statistiques */}
|
|
<Card title="📊 Statistiques">
|
|
<div className="flex flex-column gap-3">
|
|
<div className="flex justify-content-between">
|
|
<span>Total maintenances:</span>
|
|
<Badge value={vueCalendrier.evenements.length} severity="info" />
|
|
</div>
|
|
<div className="flex justify-content-between">
|
|
<span>Urgentes:</span>
|
|
<Badge value={vueCalendrier.evenements.filter(e => e.type === 'URGENTE').length} severity="danger" />
|
|
</div>
|
|
<div className="flex justify-content-between">
|
|
<span>Préventives:</span>
|
|
<Badge value={vueCalendrier.evenements.filter(e => e.type === 'PREVENTIVE').length} severity="info" />
|
|
</div>
|
|
<div className="flex justify-content-between">
|
|
<span>En cours:</span>
|
|
<Badge value={vueCalendrier.evenements.filter(e => e.statut === 'EN_COURS').length} severity="warning" />
|
|
</div>
|
|
<div className="flex justify-content-between">
|
|
<span>Terminées:</span>
|
|
<Badge value={vueCalendrier.evenements.filter(e => e.statut === 'TERMINEE').length} severity="success" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Dialog Détail Événement */}
|
|
<Dialog
|
|
visible={detailDialog}
|
|
style={{ width: '50vw' }}
|
|
header="Détail de la Maintenance"
|
|
modal
|
|
onHide={() => setDetailDialog(false)}
|
|
footer={
|
|
<div>
|
|
<Button
|
|
label="Fermer"
|
|
icon="pi pi-times"
|
|
className="p-button-outlined"
|
|
onClick={() => setDetailDialog(false)}
|
|
/>
|
|
<Button
|
|
label="Voir Détails"
|
|
icon="pi pi-eye"
|
|
className="p-button-info"
|
|
onClick={() => {
|
|
if (selectedEvenement) {
|
|
router.push(`/maintenance/${selectedEvenement.id}`);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
}
|
|
>
|
|
{selectedEvenement && (
|
|
<div className="grid">
|
|
<div className="col-12">
|
|
<h4>{selectedEvenement.title}</h4>
|
|
<div className="flex gap-2 mb-3">
|
|
<Tag value={selectedEvenement.type} severity={getTypeSeverity(selectedEvenement.type)} />
|
|
<Tag value={selectedEvenement.statut} severity={getStatutSeverity(selectedEvenement.statut)} />
|
|
</div>
|
|
</div>
|
|
<div className="col-12">
|
|
<p><strong>Matériel:</strong> {selectedEvenement.materielNom}</p>
|
|
<p><strong>Technicien:</strong> {selectedEvenement.technicienNom || 'Non assigné'}</p>
|
|
<p><strong>Date:</strong> {selectedEvenement.start.toLocaleDateString('fr-FR')}</p>
|
|
<p><strong>Durée estimée:</strong> {selectedEvenement.dureeEstimee}h</p>
|
|
<p><strong>Description:</strong> {selectedEvenement.description}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CalendrierMaintenancePage;
|