Dans le précédent article, nous avons créé un calendrier de base avec un composant Livewire. D'autre part, nous avons créé des événements aléatoires associés aux utilisateurs. À présent que nous disposons de ces bases solides, nous pouvons envisager de rendre notre calendrier réactif et de lui donner la possibilité d'afficher le détail et de supprimer des événements. Nous allons aussi améliorer son aspect.
Vous pouvez télécharger le code final de cet article ici.
Modifier rapidement un événement
Rendre les événements modifiables
FullCalendar propose la propriété editable qui est par défaut false. Lorsqu'on affecte true à cette propriété, on peut alors modifier les événements directement sur le calendrier : déplacement et changement de dimension. Pour bien visualiser tout ça vous pouvez accéder à cette démonstration sur le site officiel.
Dans notre calendrier, on affecte cette propriété :
document.addEventListener('DOMContentLoaded', function () {
...
editable: true,
On peut à présent modifier la fin d'un événement :

Et on peut également le déplacer :

Si on veut également pouvoir déplacer le début d'un événement, il faut aussi affecter true à cette propriété :
document.addEventListener('DOMContentLoaded', function () {
...
eventResizableFromStart: true,
On a donc un contrôle total sur les événements allday. Par contre, on ne peut pas changer les heures de début et de fin d'un événement classique. D'autre part, on ne peut pas non plus changer toutes les autres informations : titre, description, couleur... C'est donc pratique pour certaines actions, mais il faudra prévoir des formulaires pour être complets.
Réagir à une modification
FullCalendar est bien équipé en événements Javascript. On va utiliser eventResize et eventDrop. pour actualiser notre base de données lorsqu'un événement change de dimension ou est déplacé.
Dans la classe Calendar, on crée une fonction pour modifier les dates de début et de fin d'un événement :
public function eventChange(array $eventData): void
{
$event = Event::find($eventData['id']);
if (!$event) {
return;
}
// Déterminer si l'événement est "all-day" (journée entière)
$isAllDay = (bool) Arr::get($eventData, 'allDay', false);
// Parse la date de début
$startDate = Carbon::parse($eventData['start']);
if ($isAllDay) {
// Pour un événement "all-day", on stocke la date du début à minuit
$startDate = $startDate->startOfDay();
$event->start_date = $startDate->toDateString();
$event->is_all_day = true;
} else {
// Pour un événement temporisé, on ajuste au fuseau horaire
$event->start_date = $startDate->setTimezone(config('app.timezone'))->toDateTimeString();
$event->is_all_day = false;
}
// Gestion de la date de fin
if (!empty($eventData['end'])) {
$endDate = Carbon::parse($eventData['end']);
if ($isAllDay) {
// Pour "all-day", la fin est le jour précédent à minuit
$endDate = $endDate->subDay()->startOfDay();
$event->end_date = $endDate->toDateString();
} else {
// Pour temporisé, on ajuste au fuseau
$event->end_date = $endDate->setTimezone(config('app.timezone'))->toDateTimeString();
}
} else {
$event->end_date = null;
}
$event->save();
}
J'ai ajouté des commentaires pour la compréhension. Le plus délicat est de bien gérer les événements différents : ceux allday et les normaux.
Pour la vue je vous remets tout le Javascript parce que j'ai aussi optimisé le code du loading :
@push('scripts')
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<script>
const handleEventChange = (info) => {
@this.call('eventChange', {
id: info.event.id,
start: info.event.startStr,
end: info.event.endStr,
allDay: info.event.allDay
}).catch(() => info.revert());
};
document.addEventListener('DOMContentLoaded', function () {
const calendarEl = document.getElementById('calendar');
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
locale: '{{ config('app.locale') }}',
timeZone: '{{ config('app.timezone') }}',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
editable: true,
eventResizableFromStart: true,
events: '{{ route('events') }}',
loading: function(isLoading) {
calendarEl.style.opacity = isLoading ? '0.6' : '1';
calendarEl.style.pointerEvents = isLoading ? 'none' : 'auto';
},
eventResize: handleEventChange,
eventDrop: handleEventChange,
});
calendar.render();
});
</script>
@endpush
En cas de resize ou de drop, on appelle la fonction evenChange de la classe. À présent, quand vous faites une de ces actions dans le calendrier, la table events se met à jour.
Afficher les détails d'un événement
Sur le calendrier, on ne voit pas toutes les informations des événements. Il faut donc prévoir quelque chose pour les afficher. FullCalendar est équipé d'un événement Javascript eventClick qu'on va utiliser pour appeler une fonction de notre classe qui va provoquer l'affichage d'une feuille modale. Il faut aussi penser à gérer la fermeture de la modale.
La vue
On ajoute la modale dans la vue :
@php use Carbon\Carbon; @endphp
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-6">
Mon agenda
</h1>
<div class="max-w-6xl mx-auto" wire:ignore>
<div id='calendar'></div>
</div>
<!-- Modale de détail de l'événement -->
@if($showDetailModal && $selectedEvent)
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
@click.self="$wire.closeDetailModal()">
<div class="relative top-20 mx-auto p-6 border w-full max-w-md shadow-lg rounded-md bg-white">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-gray-900">{{ $selectedEvent['title'] }}</h3>
<button wire:click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Détails de l'événement -->
<div class="space-y-4">
<!-- Badge couleur -->
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full" style="background-color: {{ $selectedEvent['color'] }}"></div>
<span class="text-sm text-gray-500">
{{ $selectedEvent['is_all_day'] ? 'Toute la journée' : 'Événement programmé' }}
</span>
</div>
<!-- Dates -->
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-gray-400 mt-0.5" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<div>
<p class="text-sm font-medium text-gray-700">
@if($selectedEvent['is_all_day'])
{{ Carbon::parse($selectedEvent['start_date'])->isoFormat('LL') }}
@if($selectedEvent['start_date'] != $selectedEvent['end_date'])
→ {{ Carbon::parse($selectedEvent['end_date'])->isoFormat('LL') }}
@endif
@else
{{ Carbon::parse($selectedEvent['start_date'])->isoFormat('LLLL') }}
<br>
→ {{ Carbon::parse($selectedEvent['end_date'])->isoFormat('LLLL') }}
@endif
</p>
</div>
</div>
<!-- Lieu -->
@if($selectedEvent['location'])
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-gray-400 mt-0.5" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-sm text-gray-700">{{ $selectedEvent['location'] }}</p>
</div>
@endif
<!-- Description -->
@if($selectedEvent['description'])
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-gray-400 mt-0.5" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"/>
</svg>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ $selectedEvent['description'] }}</p>
</div>
@endif
</div>
</div>
</div>
@endif
@push('scripts')
Pensez à régénérer les assets pour avoir toutes les classes de Tailwind !
La classe
On complète la classe :
use App\Models\Event;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Calendar extends Component
{
public bool $showDetailModal = false;
public ?array $selectedEvent = null;
public function showEventDetail($eventId): void
{
$event = Event::find($eventId);
if (!$event || $event->user_id !== Auth::id()) {
return;
}
$this->selectedEvent = $event->toArray();
$this->showDetailModal = true;
}
public function closeDetailModal(): void
{
$this->showDetailModal = false;
$this->selectedEvent = null;
}
À présent, lorsque vous cliquez sur un événement, une modale s'ouvre avec le détail de cet événement :

Supprimer un événement
On doit pouvoir supprimer facilement un événement. Le plus simple est d'ajouter un bouton dans la modale qui affiche les détails :
<!-- Boutons d'action -->
<div class="flex justify-end gap-2 mt-6 pt-4 border-t">
<button
wire:click="deleteEvent"
wire:confirm="Êtes-vous sûr de vouloir supprimer cet événement ?"
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition"
>
Supprimer
</button>
</div>

Pour améliorer les interactions, on prévoit aussi une alerte en haut de la page :
@if (session()->has('message'))
<div class="max-w-4xl mx-auto mb-4 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
{{ session('message') }}
</div>
@endif
<div class="max-w-6xl mx-auto" wire:ignore>
<div id='calendar'></div>
</div>
Dans la classe, on doit ajouter le code de suppression :
public function deleteEvent(): void
{
$event = Event::find($this->selectedEvent['id']);
if ($event && $event->user_id === Auth::id()) {
$eventId = $event->id;
$event->delete();
$this->showDetailModal = false;
$this->selectedEvent = null;
$this->dispatch('eventDeleted', $eventId);
session()->flash('message', 'Événement supprimé avec succès.');
}
}
On récupère l'événement dans la base et on le supprime. Mais il faut aussi prévenir le calendrier que l'événement doit disparaître. D'où l'envoi de l'événement evenDeleted dans le composant du calendrier. Il faut récupérer cette information dans le composant et supprimer effectivement l'événement :
calendar.render();
window.addEventListener('eventDeleted', (event) => {
const eventId = event.detail;
const calendarEvent = calendar.getEventById(eventId);
if (calendarEvent) {
calendarEvent.remove();
}
});

Un peu d'esthétique
Si vous allez sur la page des démonstrations de FullCalndar, vous trouvez la possibilité de changer le thème général du calendrier. J'ai compté 23 thèmes à disposition. Vous avez donc du choix. Une autre possibilité consiste à créer votre propre style. C'est l'option que j'ai retenue pour avoir plus de créativité.
Votre fichier app.css doit contenir actuellement que ce code de base :
@import 'tailwindcss';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
On ajoute notre code personnalisé juste après :
/* Style général du calendrier */
#calendar {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
padding: 1.5rem;
}
/* Header toolbar - Boutons de navigation */
.fc .fc-toolbar {
gap: 1rem;
margin-bottom: 1.5rem !important;
}
.fc .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
/* Tous les boutons */
.fc .fc-button {
background: white !important;
border: 1px solid #e5e7eb !important;
color: #374151 !important;
padding: 0.5rem 1rem !important;
border-radius: 0.5rem !important;
font-weight: 500 !important;
text-transform: capitalize !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important;
transition: all 0.2s !important;
}
.fc .fc-button:hover {
background: #f9fafb !important;
border-color: #d1d5db !important;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1) !important;
}
.fc .fc-button:active,
.fc .fc-button-active {
background: #3b82f6 !important;
border-color: #3b82f6 !important;
color: white !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important;
}
.fc .fc-button:focus {
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.3) !important;
}
/* Groupe de boutons (prev/next/today) */
.fc .fc-button-group {
gap: 0.25rem;
}
.fc .fc-button-group > .fc-button {
border-radius: 0.5rem !important;
}
/* En-têtes des jours de la semaine */
.fc .fc-col-header-cell {
background: #f9fafb;
border-color: #e5e7eb !important;
padding: 0.75rem 0.5rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
/* Cellules du calendrier */
.fc .fc-daygrid-day {
border-color: #e5e7eb !important;
}
.fc .fc-daygrid-day:hover {
background: #f9fafb;
}
.fc .fc-daygrid-day-number {
padding: 0.5rem;
font-weight: 500;
color: #374151;
}
/* Aujourd'hui */
.fc .fc-day-today {
background: #eff6ff !important;
}
.fc .fc-day-today .fc-daygrid-day-number {
background: #3b82f6;
color: white;
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0.25rem;
}
/* Événements */
.fc-event {
border: none !important;
border-radius: 0.375rem !important;
padding: 0.25rem 0.5rem !important;
font-weight: 500 !important;
font-size: 0.875rem !important;
cursor: pointer !important;
transition: all 0.2s !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important;
}
.fc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1) !important;
}
.fc-event-title {
font-weight: 500;
}
/* Vue semaine/jour - timeline */
.fc .fc-timegrid-slot {
height: 3rem;
border-color: #e5e7eb !important;
}
.fc .fc-timegrid-slot-label {
color: #6b7280;
font-size: 0.875rem;
}
/* Ligne de l'heure actuelle */
.fc .fc-timegrid-now-indicator-line {
border-color: #ef4444 !important;
border-width: 2px !important;
}
.fc .fc-timegrid-now-indicator-arrow {
border-color: #ef4444 !important;
}
/* Vue liste */
.fc .fc-list-event:hover td {
background: #f9fafb !important;
}
.fc .fc-list-day-cushion {
background: #f9fafb;
color: #374151;
font-weight: 600;
}
/* Sélection de plage de dates */
.fc .fc-highlight {
background: #dbeafe !important;
opacity: 0.5;
}
/* Plus d'événements ("+2 more") */
.fc .fc-daygrid-more-link {
color: #3b82f6 !important;
font-weight: 600 !important;
font-size: 0.75rem !important;
}
.fc .fc-daygrid-more-link:hover {
background: #eff6ff !important;
border-radius: 0.25rem;
}
/* Popover pour "more events" */
.fc .fc-popover {
border: 1px solid #e5e7eb !important;
border-radius: 0.5rem !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1) !important;
}
.fc .fc-popover-header {
background: #f9fafb !important;
border-color: #e5e7eb !important;
padding: 0.75rem !important;
font-weight: 600 !important;
}
/* Responsive */
@media (max-width: 768px) {
.fc .fc-toolbar {
flex-direction: column;
gap: 0.5rem;
}
.fc .fc-toolbar-title {
font-size: 1.25rem;
}
.fc .fc-button {
padding: 0.375rem 0.75rem !important;
font-size: 0.875rem !important;
}
}
N'oubliez pas de régénérer les assets. Voici le nouvel aspect sobre et moderne de notre calendrier :

Vous pouvez vous amuser avec d'autres styles !
Conclusion
Dans cette deuxième étape, nous avons bien avancé dans la création de notre agenda. Nous pouvons à présent afficher les détails des événements et les supprimer. Nous pouvons aussi apporter des modifications rapides avec des actions de la souris. Nous avons également amélioré l'aspect global avec du style personnalisé. La prochaine étape sera de permettre la création et la modification des événements à partir d'un formulaire.
Par bestmomo
Nombre de commentaires : 2