1017 lines
44 KiB
TypeScript
1017 lines
44 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Calendar } from 'primereact/calendar';
|
|
import { Card } from 'primereact/card';
|
|
import { Button } from 'primereact/button';
|
|
import { DataTable } from 'primereact/datatable';
|
|
import { Column } from 'primereact/column';
|
|
import { Toast } from 'primereact/toast';
|
|
import { Tag } from 'primereact/tag';
|
|
import { Toolbar } from 'primereact/toolbar';
|
|
import { InputText } from 'primereact/inputtext';
|
|
import { Dropdown } from 'primereact/dropdown';
|
|
import { Dialog } from 'primereact/dialog';
|
|
import { InputTextarea } from 'primereact/inputtextarea';
|
|
import { MultiSelect } from 'primereact/multiselect';
|
|
import { Splitter, SplitterPanel } from 'primereact/splitter';
|
|
import { Panel } from 'primereact/panel';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Badge } from 'primereact/badge';
|
|
import { Timeline } from 'primereact/timeline';
|
|
import { Divider } from 'primereact/divider';
|
|
import { chantierService } from '../../../../services/api';
|
|
import { formatDate, formatDateTime } from '../../../../utils/formatters';
|
|
import type { Chantier, PlanningEvent as PlanningEventType, Equipe, Materiel, Employe, PlanningConflict } from '../../../../types/btp';
|
|
import { TypeConflitPlanification, GraviteConflict } from '../../../../types/btp';
|
|
|
|
interface PlanningCalendrierEvent {
|
|
id: string;
|
|
title: string;
|
|
start: Date;
|
|
end: Date;
|
|
allDay?: boolean;
|
|
resource?: any;
|
|
type: 'chantier' | 'reunion' | 'formation' | 'maintenance' | 'conge' | 'autre';
|
|
priority: 'basse' | 'normale' | 'haute' | 'critique';
|
|
status: 'planifie' | 'confirme' | 'en_cours' | 'termine' | 'annule';
|
|
chantier?: Chantier;
|
|
equipe?: Equipe;
|
|
employes?: Employe[];
|
|
materiels?: Materiel[];
|
|
color?: string;
|
|
description?: string;
|
|
}
|
|
|
|
const CalendrierPlanningPage = () => {
|
|
const [events, setEvents] = useState<PlanningCalendrierEvent[]>([]);
|
|
const [chantiers, setChantiers] = useState<Chantier[]>([]);
|
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
const [selectedEvent, setSelectedEvent] = useState<PlanningCalendrierEvent | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [viewMode, setViewMode] = useState<'month' | 'week' | 'day' | 'agenda'>('month');
|
|
const [filterType, setFilterType] = useState<string>('');
|
|
const [filterEquipe, setFilterEquipe] = useState<string>('');
|
|
const [filterPriorite, setFilterPriorite] = useState<string>('');
|
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
|
|
// Dialogs
|
|
const [eventDialog, setEventDialog] = useState(false);
|
|
const [conflictDialog, setConflictDialog] = useState(false);
|
|
const [newEventDialog, setNewEventDialog] = useState(false);
|
|
|
|
// State pour nouvel événement
|
|
const [newEvent, setNewEvent] = useState<Partial<PlanningCalendrierEvent>>({
|
|
title: '',
|
|
description: '',
|
|
start: new Date(),
|
|
end: new Date(Date.now() + 2 * 60 * 60 * 1000), // +2h par défaut
|
|
type: 'autre',
|
|
priority: 'normale',
|
|
status: 'planifie',
|
|
allDay: false
|
|
});
|
|
|
|
const [conflicts, setConflicts] = useState<PlanningConflict[]>([]);
|
|
const [stats, setStats] = useState({
|
|
totalEvents: 0,
|
|
eventsAujourdHui: 0,
|
|
eventsSemaine: 0,
|
|
eventsCritiques: 0,
|
|
tauxOccupation: 0
|
|
});
|
|
|
|
const toast = useRef<Toast>(null);
|
|
|
|
const typeOptions = [
|
|
{ label: 'Tous les types', value: '' },
|
|
{ label: 'Chantier', value: 'chantier' },
|
|
{ label: 'Réunion', value: 'reunion' },
|
|
{ label: 'Formation', value: 'formation' },
|
|
{ label: 'Maintenance', value: 'maintenance' },
|
|
{ label: 'Congé', value: 'conge' },
|
|
{ label: 'Autre', value: 'autre' }
|
|
];
|
|
|
|
const prioriteOptions = [
|
|
{ label: 'Toutes priorités', value: '' },
|
|
{ label: 'Basse', value: 'basse' },
|
|
{ label: 'Normale', value: 'normale' },
|
|
{ label: 'Haute', value: 'haute' },
|
|
{ label: 'Critique', value: 'critique' }
|
|
];
|
|
|
|
const statutOptions = [
|
|
{ label: 'Planifié', value: 'planifie' },
|
|
{ label: 'Confirmé', value: 'confirme' },
|
|
{ label: 'En cours', value: 'en_cours' },
|
|
{ label: 'Terminé', value: 'termine' },
|
|
{ label: 'Annulé', value: 'annule' }
|
|
];
|
|
|
|
const viewModeOptions = [
|
|
{ label: 'Mois', value: 'month' },
|
|
{ label: 'Semaine', value: 'week' },
|
|
{ label: 'Jour', value: 'day' },
|
|
{ label: 'Agenda', value: 'agenda' }
|
|
];
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
generateMockEvents();
|
|
calculateStats();
|
|
detectConflicts();
|
|
}, [chantiers, selectedDate]);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const chantiersData = await chantierService.getAll();
|
|
setChantiers(chantiersData);
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des données:', error);
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de charger les données',
|
|
life: 3000
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const generateMockEvents = () => {
|
|
const mockEvents: PlanningCalendrierEvent[] = [];
|
|
const now = new Date();
|
|
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 2, 0);
|
|
|
|
// Générer des événements basés sur les chantiers
|
|
chantiers.forEach((chantier, index) => {
|
|
const startDate = new Date(chantier.dateDebut);
|
|
const endDate = chantier.dateFinPrevue ? new Date(chantier.dateFinPrevue) : new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
if (startDate >= thisMonth && startDate <= nextMonth) {
|
|
mockEvents.push({
|
|
id: `chantier-${chantier.id}`,
|
|
title: `Chantier: ${chantier.nom}`,
|
|
start: startDate,
|
|
end: endDate,
|
|
type: 'chantier',
|
|
priority: index % 4 === 0 ? 'haute' : 'normale',
|
|
status: chantier.statut === 'EN_COURS' ? 'en_cours' :
|
|
chantier.statut === 'TERMINE' ? 'termine' :
|
|
chantier.statut === 'PLANIFIE' ? 'planifie' : 'confirme',
|
|
chantier: chantier,
|
|
color: getEventColor('chantier', chantier.statut === 'EN_COURS' ? 'en_cours' : 'planifie'),
|
|
description: `Chantier ${chantier.nom} - ${chantier.adresse}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Ajouter des événements de maintenance
|
|
for (let i = 0; i < 5; i++) {
|
|
const eventDate = new Date(now.getTime() + (Math.random() * 30 - 15) * 24 * 60 * 60 * 1000);
|
|
mockEvents.push({
|
|
id: `maintenance-${i}`,
|
|
title: `Maintenance ${['Camion', 'Grue', 'Échafaudage', 'Bétonnière', 'Compresseur'][i]}`,
|
|
start: eventDate,
|
|
end: new Date(eventDate.getTime() + 4 * 60 * 60 * 1000), // 4h
|
|
type: 'maintenance',
|
|
priority: 'normale',
|
|
status: 'planifie',
|
|
color: getEventColor('maintenance', 'planifie'),
|
|
description: `Maintenance préventive programmée`
|
|
});
|
|
}
|
|
|
|
// Ajouter des réunions
|
|
for (let i = 0; i < 8; i++) {
|
|
const eventDate = new Date(now.getTime() + (Math.random() * 20 - 10) * 24 * 60 * 60 * 1000);
|
|
eventDate.setHours(9 + Math.floor(Math.random() * 8)); // Entre 9h et 17h
|
|
eventDate.setMinutes(0);
|
|
|
|
mockEvents.push({
|
|
id: `reunion-${i}`,
|
|
title: `Réunion ${['Équipe', 'Client', 'Sécurité', 'Planning', 'Suivi', 'Formation', 'Qualité', 'Budget'][i]}`,
|
|
start: eventDate,
|
|
end: new Date(eventDate.getTime() + (1 + Math.random()) * 60 * 60 * 1000), // 1-2h
|
|
type: 'reunion',
|
|
priority: i < 2 ? 'haute' : 'normale',
|
|
status: 'confirme',
|
|
color: getEventColor('reunion', 'confirme'),
|
|
description: `Réunion d'équipe programmée`
|
|
});
|
|
}
|
|
|
|
setEvents(mockEvents);
|
|
};
|
|
|
|
const getEventColor = (type: string, status: string) => {
|
|
const colors = {
|
|
chantier: {
|
|
planifie: '#3B82F6',
|
|
confirme: '#10B981',
|
|
en_cours: '#F59E0B',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
},
|
|
reunion: {
|
|
planifie: '#8B5CF6',
|
|
confirme: '#6366F1',
|
|
en_cours: '#A855F7',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
},
|
|
formation: {
|
|
planifie: '#06B6D4',
|
|
confirme: '#0891B2',
|
|
en_cours: '#0E7490',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
},
|
|
maintenance: {
|
|
planifie: '#F97316',
|
|
confirme: '#EA580C',
|
|
en_cours: '#DC2626',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
},
|
|
conge: {
|
|
planifie: '#84CC16',
|
|
confirme: '#65A30D',
|
|
en_cours: '#65A30D',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
},
|
|
autre: {
|
|
planifie: '#6B7280',
|
|
confirme: '#4B5563',
|
|
en_cours: '#374151',
|
|
termine: '#6B7280',
|
|
annule: '#EF4444'
|
|
}
|
|
};
|
|
return colors[type as keyof typeof colors]?.[status as keyof typeof colors.chantier] || '#6B7280';
|
|
};
|
|
|
|
const calculateStats = () => {
|
|
const today = new Date();
|
|
const weekStart = new Date(today.setDate(today.getDate() - today.getDay()));
|
|
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const eventsAujourdHui = events.filter(event => {
|
|
const eventDate = new Date(event.start);
|
|
return eventDate.toDateString() === new Date().toDateString();
|
|
}).length;
|
|
|
|
const eventsSemaine = events.filter(event => {
|
|
const eventDate = new Date(event.start);
|
|
return eventDate >= weekStart && eventDate <= weekEnd;
|
|
}).length;
|
|
|
|
const eventsCritiques = events.filter(event => event.priority === 'critique').length;
|
|
|
|
// Calculer le taux d'occupation (approximatif)
|
|
const totalHours = events.reduce((total, event) => {
|
|
const duration = (event.end.getTime() - event.start.getTime()) / (1000 * 60 * 60);
|
|
return total + duration;
|
|
}, 0);
|
|
const tauxOccupation = Math.min(100, Math.round((totalHours / (7 * 8)) * 100)); // Basé sur 7 jours de 8h
|
|
|
|
setStats({
|
|
totalEvents: events.length,
|
|
eventsAujourdHui,
|
|
eventsSemaine,
|
|
eventsCritiques,
|
|
tauxOccupation
|
|
});
|
|
};
|
|
|
|
const detectConflicts = () => {
|
|
const conflicts: PlanningConflict[] = [];
|
|
|
|
// Détecter les chevauchements d'horaires
|
|
for (let i = 0; i < events.length; i++) {
|
|
for (let j = i + 1; j < events.length; j++) {
|
|
const event1 = events[i];
|
|
const event2 = events[j];
|
|
|
|
if (event1.start < event2.end && event1.end > event2.start) {
|
|
conflicts.push({
|
|
id: `conflict-${i}-${j}`,
|
|
type: TypeConflitPlanification.CHEVAUCHEMENT_HORAIRES,
|
|
description: `Conflit horaire entre "${event1.title}" et "${event2.title}"`,
|
|
events: [event1, event2] as any,
|
|
ressources: [],
|
|
gravite: GraviteConflict.ATTENTION,
|
|
suggestions: [
|
|
'Décaler l\'un des événements',
|
|
'Réduire la durée des événements',
|
|
'Assigner des équipes différentes'
|
|
]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setConflicts(conflicts);
|
|
};
|
|
|
|
const getFilteredEvents = () => {
|
|
let filtered = [...events];
|
|
|
|
if (filterType) {
|
|
filtered = filtered.filter(event => event.type === filterType);
|
|
}
|
|
|
|
if (filterPriorite) {
|
|
filtered = filtered.filter(event => event.priority === filterPriorite);
|
|
}
|
|
|
|
if (globalFilter) {
|
|
const searchTerm = globalFilter.toLowerCase();
|
|
filtered = filtered.filter(event =>
|
|
event.title.toLowerCase().includes(searchTerm) ||
|
|
event.description?.toLowerCase().includes(searchTerm)
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
};
|
|
|
|
const getEventsForDate = (date: Date) => {
|
|
return getFilteredEvents().filter(event => {
|
|
const eventDate = new Date(event.start);
|
|
return eventDate.toDateString() === date.toDateString();
|
|
});
|
|
};
|
|
|
|
const onEventClick = (event: PlanningCalendrierEvent) => {
|
|
setSelectedEvent(event);
|
|
setEventDialog(true);
|
|
};
|
|
|
|
const onDateSelect = (date: Date) => {
|
|
setSelectedDate(date);
|
|
};
|
|
|
|
const openNewEventDialog = () => {
|
|
setNewEvent({
|
|
title: '',
|
|
description: '',
|
|
start: selectedDate,
|
|
end: new Date(selectedDate.getTime() + 2 * 60 * 60 * 1000),
|
|
type: 'autre',
|
|
priority: 'normale',
|
|
status: 'planifie',
|
|
allDay: false
|
|
});
|
|
setNewEventDialog(true);
|
|
};
|
|
|
|
const saveNewEvent = () => {
|
|
if (newEvent.title && newEvent.start && newEvent.end) {
|
|
const event: PlanningCalendrierEvent = {
|
|
id: `new-${Date.now()}`,
|
|
title: newEvent.title,
|
|
start: newEvent.start,
|
|
end: newEvent.end,
|
|
type: newEvent.type || 'autre',
|
|
priority: newEvent.priority || 'normale',
|
|
status: newEvent.status || 'planifie',
|
|
description: newEvent.description,
|
|
color: getEventColor(newEvent.type || 'autre', newEvent.status || 'planifie'),
|
|
allDay: newEvent.allDay
|
|
};
|
|
|
|
setEvents([...events, event]);
|
|
setNewEventDialog(false);
|
|
|
|
toast.current?.show({
|
|
severity: 'success',
|
|
summary: 'Succès',
|
|
detail: 'Événement créé avec succès',
|
|
life: 3000
|
|
});
|
|
}
|
|
};
|
|
|
|
const statusBodyTemplate = (event: PlanningCalendrierEvent) => {
|
|
const getSeverity = (status: string) => {
|
|
switch (status) {
|
|
case 'planifie': return 'info';
|
|
case 'confirme': return 'success';
|
|
case 'en_cours': return 'warning';
|
|
case 'termine': return 'secondary';
|
|
case 'annule': return 'danger';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
const getLabel = (status: string) => {
|
|
switch (status) {
|
|
case 'planifie': return 'Planifié';
|
|
case 'confirme': return 'Confirmé';
|
|
case 'en_cours': return 'En cours';
|
|
case 'termine': return 'Terminé';
|
|
case 'annule': return 'Annulé';
|
|
default: return status;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Tag
|
|
value={getLabel(event.status)}
|
|
severity={getSeverity(event.status)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const priorityBodyTemplate = (event: PlanningCalendrierEvent) => {
|
|
const getSeverity = (priority: string) => {
|
|
switch (priority) {
|
|
case 'basse': return 'secondary';
|
|
case 'normale': return 'info';
|
|
case 'haute': return 'warning';
|
|
case 'critique': return 'danger';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
const getLabel = (priority: string) => {
|
|
switch (priority) {
|
|
case 'basse': return 'Basse';
|
|
case 'normale': return 'Normale';
|
|
case 'haute': return 'Haute';
|
|
case 'critique': return 'Critique';
|
|
default: return priority;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Tag
|
|
value={getLabel(event.priority)}
|
|
severity={getSeverity(event.priority)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const typeBodyTemplate = (event: PlanningCalendrierEvent) => {
|
|
const getIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'chantier': return 'pi pi-building';
|
|
case 'reunion': return 'pi pi-users';
|
|
case 'formation': return 'pi pi-book';
|
|
case 'maintenance': return 'pi pi-wrench';
|
|
case 'conge': return 'pi pi-calendar-times';
|
|
default: return 'pi pi-circle';
|
|
}
|
|
};
|
|
|
|
const getLabel = (type: string) => {
|
|
switch (type) {
|
|
case 'chantier': return 'Chantier';
|
|
case 'reunion': return 'Réunion';
|
|
case 'formation': return 'Formation';
|
|
case 'maintenance': return 'Maintenance';
|
|
case 'conge': return 'Congé';
|
|
default: return 'Autre';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex align-items-center">
|
|
<i className={`${getIcon(event.type)} mr-2`} />
|
|
{getLabel(event.type)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const timeBodyTemplate = (event: PlanningCalendrierEvent) => {
|
|
if (event.allDay) {
|
|
return <span className="text-sm">Toute la journée</span>;
|
|
}
|
|
return (
|
|
<div className="flex flex-column">
|
|
<span className="text-sm font-semibold">
|
|
{formatDateTime(event.start)} - {formatDateTime(event.end)}
|
|
</span>
|
|
<span className="text-xs text-color-secondary">
|
|
Durée: {Math.round((event.end.getTime() - event.start.getTime()) / (1000 * 60 * 60))}h
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const actionBodyTemplate = (event: PlanningCalendrierEvent) => {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
icon="pi pi-eye"
|
|
size="small"
|
|
severity="info"
|
|
outlined
|
|
onClick={() => onEventClick(event)}
|
|
/>
|
|
<Button
|
|
icon="pi pi-pencil"
|
|
size="small"
|
|
severity={"secondary" as any}
|
|
outlined
|
|
/>
|
|
<Button
|
|
icon="pi pi-trash"
|
|
size="small"
|
|
severity="danger"
|
|
outlined
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const leftToolbarTemplate = () => {
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
label="Nouvel événement"
|
|
icon="pi pi-plus"
|
|
severity="success"
|
|
onClick={openNewEventDialog}
|
|
/>
|
|
<Button
|
|
label="Aujourd'hui"
|
|
icon="pi pi-calendar"
|
|
severity="info"
|
|
outlined
|
|
onClick={() => setSelectedDate(new Date())}
|
|
/>
|
|
<Dropdown
|
|
value={viewMode}
|
|
options={viewModeOptions}
|
|
onChange={(e) => setViewMode(e.value)}
|
|
placeholder="Vue"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const rightToolbarTemplate = () => {
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Dropdown
|
|
value={filterType}
|
|
options={typeOptions}
|
|
onChange={(e) => setFilterType(e.value)}
|
|
placeholder="Type"
|
|
showClear
|
|
/>
|
|
<Dropdown
|
|
value={filterPriorite}
|
|
options={prioriteOptions}
|
|
onChange={(e) => setFilterPriorite(e.value)}
|
|
placeholder="Priorité"
|
|
showClear
|
|
/>
|
|
<span className="p-input-icon-left">
|
|
<i className="pi pi-search" />
|
|
<InputText
|
|
type="search"
|
|
placeholder="Rechercher..."
|
|
value={globalFilter}
|
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
/>
|
|
</span>
|
|
<Button
|
|
label="Conflits"
|
|
icon="pi pi-exclamation-triangle"
|
|
severity="warning"
|
|
outlined
|
|
badge={conflicts.length.toString()}
|
|
onClick={() => setConflictDialog(true)}
|
|
disabled={conflicts.length === 0}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCalendarView = () => {
|
|
const eventsToday = getEventsForDate(selectedDate);
|
|
|
|
return (
|
|
<Splitter style={{ height: '600px' }}>
|
|
<SplitterPanel size={70}>
|
|
<div className="p-3">
|
|
<div className="flex justify-content-between align-items-center mb-3">
|
|
<h5>Calendrier {viewMode === 'month' ? 'Mensuel' :
|
|
viewMode === 'week' ? 'Hebdomadaire' :
|
|
viewMode === 'day' ? 'Journalier' : 'Agenda'}</h5>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
icon="pi pi-chevron-left"
|
|
size="small"
|
|
onClick={() => {
|
|
const newDate = new Date(selectedDate);
|
|
if (viewMode === 'month') {
|
|
newDate.setMonth(newDate.getMonth() - 1);
|
|
} else if (viewMode === 'week') {
|
|
newDate.setDate(newDate.getDate() - 7);
|
|
} else {
|
|
newDate.setDate(newDate.getDate() - 1);
|
|
}
|
|
setSelectedDate(newDate);
|
|
}}
|
|
/>
|
|
<Button
|
|
icon="pi pi-chevron-right"
|
|
size="small"
|
|
onClick={() => {
|
|
const newDate = new Date(selectedDate);
|
|
if (viewMode === 'month') {
|
|
newDate.setMonth(newDate.getMonth() + 1);
|
|
} else if (viewMode === 'week') {
|
|
newDate.setDate(newDate.getDate() + 7);
|
|
} else {
|
|
newDate.setDate(newDate.getDate() + 1);
|
|
}
|
|
setSelectedDate(newDate);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Calendar
|
|
value={selectedDate}
|
|
onChange={(e) => onDateSelect(e.value as Date)}
|
|
inline
|
|
dateFormat="dd/mm/yy"
|
|
showWeek
|
|
className="w-full"
|
|
/>
|
|
|
|
<div className="mt-3">
|
|
<h6>Légende</h6>
|
|
<div className="flex flex-wrap gap-2">
|
|
{typeOptions.slice(1).map(type => (
|
|
<div key={type.value} className="flex align-items-center gap-1">
|
|
<div
|
|
className="w-1rem h-1rem border-round"
|
|
style={{ backgroundColor: getEventColor(type.value, 'confirme') }}
|
|
/>
|
|
<span className="text-sm">{type.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SplitterPanel>
|
|
|
|
<SplitterPanel size={30}>
|
|
<div className="p-3">
|
|
<Panel header={`Événements du ${formatDate(selectedDate)}`} className="h-full">
|
|
{eventsToday.length > 0 ? (
|
|
<div className="flex flex-column gap-2">
|
|
{eventsToday.map(event => (
|
|
<Card
|
|
key={event.id}
|
|
className="cursor-pointer hover:shadow-2"
|
|
onClick={() => onEventClick(event)}
|
|
>
|
|
<div className="flex align-items-start justify-content-between">
|
|
<div className="flex-1">
|
|
<div className="flex align-items-center gap-2 mb-1">
|
|
<div
|
|
className="w-1rem h-1rem border-round"
|
|
style={{ backgroundColor: event.color }}
|
|
/>
|
|
<span className="font-semibold text-sm">{event.title}</span>
|
|
</div>
|
|
<div className="text-xs text-color-secondary mb-2">
|
|
{event.allDay ? 'Toute la journée' :
|
|
`${formatDateTime(event.start)} - ${formatDateTime(event.end)}`}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Tag
|
|
value={typeBodyTemplate(event).props.children[1]}
|
|
severity="info"
|
|
size="normal"
|
|
/>
|
|
<Tag
|
|
value={priorityBodyTemplate(event).props.value}
|
|
severity={priorityBodyTemplate(event).props.severity}
|
|
size="normal"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-color-secondary">
|
|
Aucun événement pour cette date
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</SplitterPanel>
|
|
</Splitter>
|
|
);
|
|
};
|
|
|
|
const renderStatsCards = () => {
|
|
return (
|
|
<div className="grid mb-4">
|
|
<div className="col-12 md:col-3">
|
|
<Card>
|
|
<div className="flex align-items-center">
|
|
<div className="bg-blue-100 p-3 border-round mr-3">
|
|
<i className="pi pi-calendar text-blue-500 text-2xl" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-color">{stats.totalEvents}</div>
|
|
<div className="text-color-secondary">Événements total</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 md:col-3">
|
|
<Card>
|
|
<div className="flex align-items-center">
|
|
<div className="bg-green-100 p-3 border-round mr-3">
|
|
<i className="pi pi-clock text-green-500 text-2xl" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-color">{stats.eventsAujourdHui}</div>
|
|
<div className="text-color-secondary">Aujourd'hui</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 md:col-3">
|
|
<Card>
|
|
<div className="flex align-items-center">
|
|
<div className="bg-orange-100 p-3 border-round mr-3">
|
|
<i className="pi pi-exclamation-triangle text-orange-500 text-2xl" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-color">{stats.eventsCritiques}</div>
|
|
<div className="text-color-secondary">Critiques</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 md:col-3">
|
|
<Card>
|
|
<div className="flex align-items-center">
|
|
<div className="bg-purple-100 p-3 border-round mr-3">
|
|
<i className="pi pi-chart-pie text-purple-500 text-2xl" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-color">{stats.tauxOccupation}%</div>
|
|
<div className="text-color-secondary">Taux d'occupation</div>
|
|
<ProgressBar value={stats.tauxOccupation} className="mt-1" style={{ height: '4px' }} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="grid">
|
|
<div className="col-12">
|
|
<Card>
|
|
<Toast ref={toast} />
|
|
|
|
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
|
|
|
|
{renderStatsCards()}
|
|
|
|
{viewMode === 'agenda' ? (
|
|
<DataTable
|
|
value={getFilteredEvents()}
|
|
paginator
|
|
rows={15}
|
|
dataKey="id"
|
|
loading={loading}
|
|
globalFilter={globalFilter}
|
|
emptyMessage="Aucun événement trouvé"
|
|
className="datatable-responsive"
|
|
>
|
|
<Column field="title" header="Événement" sortable style={{ minWidth: '200px' }} />
|
|
<Column field="type" header="Type" body={typeBodyTemplate} sortable style={{ minWidth: '120px' }} />
|
|
<Column field="priority" header="Priorité" body={priorityBodyTemplate} sortable style={{ minWidth: '100px' }} />
|
|
<Column field="status" header="Statut" body={statusBodyTemplate} sortable style={{ minWidth: '100px' }} />
|
|
<Column field="start" header="Horaires" body={timeBodyTemplate} sortable style={{ minWidth: '200px' }} />
|
|
<Column field="description" header="Description" sortable style={{ minWidth: '200px' }} />
|
|
<Column body={actionBodyTemplate} style={{ minWidth: '120px' }} />
|
|
</DataTable>
|
|
) : (
|
|
renderCalendarView()
|
|
)}
|
|
|
|
{/* Dialog détails événement */}
|
|
<Dialog
|
|
visible={eventDialog}
|
|
style={{ width: '600px' }}
|
|
header="Détails de l'événement"
|
|
modal
|
|
onHide={() => setEventDialog(false)}
|
|
>
|
|
{selectedEvent && (
|
|
<div className="flex flex-column gap-3">
|
|
<div>
|
|
<label className="font-semibold">Titre</label>
|
|
<p>{selectedEvent.title}</p>
|
|
</div>
|
|
|
|
<div className="grid">
|
|
<div className="col-6">
|
|
<label className="font-semibold">Type</label>
|
|
<p>{typeBodyTemplate(selectedEvent)}</p>
|
|
</div>
|
|
<div className="col-6">
|
|
<label className="font-semibold">Priorité</label>
|
|
<p>{priorityBodyTemplate(selectedEvent)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid">
|
|
<div className="col-6">
|
|
<label className="font-semibold">Début</label>
|
|
<p>{formatDateTime(selectedEvent.start)}</p>
|
|
</div>
|
|
<div className="col-6">
|
|
<label className="font-semibold">Fin</label>
|
|
<p>{formatDateTime(selectedEvent.end)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="font-semibold">Statut</label>
|
|
<p>{statusBodyTemplate(selectedEvent)}</p>
|
|
</div>
|
|
|
|
{selectedEvent.description && (
|
|
<div>
|
|
<label className="font-semibold">Description</label>
|
|
<p>{selectedEvent.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedEvent.chantier && (
|
|
<div>
|
|
<label className="font-semibold">Chantier associé</label>
|
|
<p>{selectedEvent.chantier.nom} - {selectedEvent.chantier.adresse}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
|
|
{/* Dialog nouvel événement */}
|
|
<Dialog
|
|
visible={newEventDialog}
|
|
style={{ width: '700px' }}
|
|
header="Nouvel événement"
|
|
modal
|
|
onHide={() => setNewEventDialog(false)}
|
|
footer={
|
|
<div>
|
|
<Button label="Annuler" icon="pi pi-times" onClick={() => setNewEventDialog(false)} />
|
|
<Button label="Créer" icon="pi pi-check" onClick={saveNewEvent} />
|
|
</div>
|
|
}
|
|
>
|
|
<div className="formgrid grid">
|
|
<div className="field col-12">
|
|
<label htmlFor="title">Titre *</label>
|
|
<InputText
|
|
id="title"
|
|
value={newEvent.title || ''}
|
|
onChange={(e) => setNewEvent({...newEvent, title: e.target.value})}
|
|
className="w-full"
|
|
placeholder="Titre de l'événement"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field col-12 md:col-6">
|
|
<label htmlFor="type">Type</label>
|
|
<Dropdown
|
|
id="type"
|
|
value={newEvent.type}
|
|
options={typeOptions.slice(1)}
|
|
onChange={(e) => setNewEvent({...newEvent, type: e.value})}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field col-12 md:col-6">
|
|
<label htmlFor="priority">Priorité</label>
|
|
<Dropdown
|
|
id="priority"
|
|
value={newEvent.priority}
|
|
options={prioriteOptions.slice(1)}
|
|
onChange={(e) => setNewEvent({...newEvent, priority: e.value})}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field col-12 md:col-6">
|
|
<label htmlFor="start">Date/Heure début</label>
|
|
<Calendar
|
|
id="start"
|
|
value={newEvent.start}
|
|
onChange={(e) => setNewEvent({...newEvent, start: e.value as Date})}
|
|
showTime
|
|
dateFormat="dd/mm/yy"
|
|
timeFormat="24"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field col-12 md:col-6">
|
|
<label htmlFor="end">Date/Heure fin</label>
|
|
<Calendar
|
|
id="end"
|
|
value={newEvent.end}
|
|
onChange={(e) => setNewEvent({...newEvent, end: e.value as Date})}
|
|
showTime
|
|
dateFormat="dd/mm/yy"
|
|
timeFormat="24"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field col-12">
|
|
<label htmlFor="description">Description</label>
|
|
<InputTextarea
|
|
id="description"
|
|
value={newEvent.description || ''}
|
|
onChange={(e) => setNewEvent({...newEvent, description: e.target.value})}
|
|
rows={3}
|
|
className="w-full"
|
|
placeholder="Description de l'événement"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Dialog conflits */}
|
|
<Dialog
|
|
visible={conflictDialog}
|
|
style={{ width: '800px' }}
|
|
header="Conflits de planification"
|
|
modal
|
|
onHide={() => setConflictDialog(false)}
|
|
>
|
|
{conflicts.length > 0 ? (
|
|
<div className="flex flex-column gap-3">
|
|
{conflicts.map(conflict => (
|
|
<Card key={conflict.id} className="border-left-3 border-orange-500">
|
|
<div className="flex align-items-start justify-content-between">
|
|
<div className="flex-1">
|
|
<div className="flex align-items-center gap-2 mb-2">
|
|
<i className="pi pi-exclamation-triangle text-orange-500" />
|
|
<span className="font-semibold">{conflict.description}</span>
|
|
<Tag
|
|
value={conflict.gravite}
|
|
severity={conflict.gravite === 'CRITIQUE' ? 'danger' : 'warning'}
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-color-secondary">
|
|
<strong>Suggestions:</strong>
|
|
<ul className="mt-1 ml-3">
|
|
{conflict.suggestions.map((suggestion, index) => (
|
|
<li key={index}>{suggestion}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-color-secondary">Aucun conflit détecté</p>
|
|
)}
|
|
</Dialog>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CalendrierPlanningPage;
|
|
|
|
|