Files
dockezr/frontend/app/page.tsx
T
Rassaby Ludovic 35cad7998d 🚀 Release v1.1.0 - Application en Production avec Monitoring
 Nouvelles fonctionnalités:
- 🌐 Application déployée et accessible en ligne (http://141.253.118.141:3000)
- 📊 Dashboard Grafana complet avec métriques en temps réel
- 🚀 Configuration de production avec docker-compose.prod.yml
- 🏗️ Infrastructure robuste avec monitoring Prometheus + Grafana
- 📈 Métriques personnalisées (CPU, mémoire, requêtes, réservations)

🔧 Améliorations techniques:
- Backend FastAPI avec métriques Prometheus intégrées
- Configuration Docker optimisée pour la production
- Health checks pour tous les services
- Variables d'environnement sécurisées
- Configuration Ansible pour déploiement automatisé

📚 Documentation:
- README complet mis à jour avec liens de production
- CHANGELOG détaillé pour la version 1.1.0
- Notes de release complètes
- Guide de déploiement en production

🎯 Statut: Stable, déployé et accessible en ligne
2025-10-17 15:05:09 +04:00

626 lines
26 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import axios from 'axios'
import {
BuildingOffice2Icon,
CalendarIcon,
ClipboardDocumentListIcon,
ChartBarIcon,
UserGroupIcon,
WrenchScrewdriverIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
EnvelopeIcon,
DocumentTextIcon,
TrashIcon,
CheckIcon,
XMarkIcon
} from '@heroicons/react/24/outline'
interface Room {
id: number
name: string
capacity: number
equipment: string | null
description: string | null
}
interface Reservation {
id: number
room_id: number
room_name?: string
user_name: string
user_email: string
reservation_date: string
start_time: string
end_time: string
purpose: string
created_at: string
}
export default function Home() {
const [rooms, setRooms] = useState<Room[]>([])
const [reservations, setReservations] = useState<Reservation[]>([])
const [selectedRoom, setSelectedRoom] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'rooms' | 'reservations' | 'planning'>('planning')
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
// Formulaire de réservation
const [formData, setFormData] = useState({
user_name: '',
user_email: '',
reservation_date: '',
start_time: '',
end_time: '',
purpose: ''
})
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://141.253.118.141:8001'
useEffect(() => {
fetchRooms()
fetchReservations()
}, [])
const fetchRooms = async () => {
try {
const response = await axios.get(`${API_URL}/rooms`)
setRooms(response.data)
} catch (error) {
console.error('Erreur lors du chargement des salles:', error)
}
}
const fetchReservations = async () => {
try {
const response = await axios.get(`${API_URL}/reservations`)
setReservations(response.data)
} catch (error) {
console.error('Erreur lors du chargement des réservations:', error)
}
}
const fetchReservationsByDate = async (date: string) => {
try {
const response = await axios.get(`${API_URL}/reservations/date/${date}`)
return response.data
} catch (error) {
console.error('Erreur lors du chargement des réservations par date:', error)
return []
}
}
const getReservationsForRoom = (roomId: number, date: string) => {
return reservations.filter(r =>
r.room_id === roomId &&
r.reservation_date === date
)
}
const isSlotAvailable = (roomId: number, date: string, startTime: string, endTime: string) => {
const roomReservations = getReservationsForRoom(roomId, date)
for (const reservation of roomReservations) {
const resStart = reservation.start_time
const resEnd = reservation.end_time
// Vérifie s'il y a chevauchement
if ((startTime < resEnd && endTime > resStart)) {
return false
}
}
return true
}
const handleReservation = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedRoom) {
alert('Veuillez sélectionner une salle')
return
}
setLoading(true)
try {
await axios.post(`${API_URL}/reservations`, {
room_id: selectedRoom,
...formData
})
alert('Réservation effectuée avec succès !')
setFormData({
user_name: '',
user_email: '',
reservation_date: '',
start_time: '',
end_time: '',
purpose: ''
})
setSelectedRoom(null)
fetchReservations()
} catch (error: any) {
if (error.response?.status === 409) {
alert('Cette salle est déjà réservée pour ce créneau horaire')
} else {
alert('Erreur lors de la réservation: ' + (error.response?.data?.detail || error.message))
}
} finally {
setLoading(false)
}
}
const handleDeleteReservation = async (id: number) => {
if (!confirm('Voulez-vous vraiment annuler cette réservation ?')) return
try {
await axios.delete(`${API_URL}/reservations/${id}`)
fetchReservations()
alert('Réservation annulée avec succès')
} catch (error) {
console.error('Erreur lors de l\'annulation:', error)
alert('Erreur lors de l\'annulation de la réservation')
}
}
return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-2">
<BuildingOffice2Icon className="w-12 h-12 text-indigo-900" />
<h1 className="text-5xl font-bold text-indigo-900">
Expernet
</h1>
</div>
<p className="text-xl text-gray-600">
Centre de Formation - Réservation de Salles
</p>
</div>
{/* Tabs */}
<div className="flex justify-center mb-8">
<div className="bg-white rounded-lg shadow-md p-1 inline-flex">
<button
onClick={() => setActiveTab('planning')}
className={`px-6 py-2 rounded-md font-medium transition-colors flex items-center gap-2 ${
activeTab === 'planning'
? 'bg-indigo-600 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<ChartBarIcon className="w-5 h-5" />
Planning des salles
</button>
<button
onClick={() => setActiveTab('rooms')}
className={`px-6 py-2 rounded-md font-medium transition-colors flex items-center gap-2 ${
activeTab === 'rooms'
? 'bg-indigo-600 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<CalendarIcon className="w-5 h-5" />
Réserver une salle
</button>
<button
onClick={() => setActiveTab('reservations')}
className={`px-6 py-2 rounded-md font-medium transition-colors flex items-center gap-2 ${
activeTab === 'reservations'
? 'bg-indigo-600 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<ClipboardDocumentListIcon className="w-5 h-5" />
Mes réservations
</button>
</div>
</div>
{/* Onglet Planning */}
{activeTab === 'planning' && (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="mb-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">
Planning des Salles
</h2>
<div className="flex items-center gap-4 mb-6">
<label className="text-sm font-medium text-gray-700">
Date :
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
{/* Grille de planning */}
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-indigo-50">
<th className="border border-gray-300 px-4 py-2 text-left font-semibold text-gray-700">
Salle
</th>
{Array.from({ length: 14 }, (_, i) => i + 8).map((hour) => (
<th key={hour} className="border border-gray-300 px-2 py-2 text-center text-sm font-medium text-gray-700">
{hour}h
</th>
))}
</tr>
</thead>
<tbody>
{rooms.map((room) => {
const roomReservations = getReservationsForRoom(room.id, selectedDate)
return (
<tr key={room.id} className="hover:bg-gray-50">
<td className="border border-gray-300 px-4 py-2 font-medium text-gray-900 whitespace-nowrap">
{room.name}
<div className="text-xs text-gray-500 flex items-center gap-1">
<UserGroupIcon className="w-3 h-3" />
{room.capacity} pers.
</div>
</td>
{Array.from({ length: 14 }, (_, i) => i + 8).map((hour) => {
const hourStart = `${hour.toString().padStart(2, '0')}:00`
const hourEnd = `${(hour + 1).toString().padStart(2, '0')}:00`
// Trouver les réservations qui couvrent cette heure
const overlappingReservations = roomReservations.filter(res => {
return res.start_time < hourEnd && res.end_time > hourStart
})
const isOccupied = overlappingReservations.length > 0
const reservation = overlappingReservations[0]
return (
<td
key={hour}
className={`border border-gray-300 px-1 py-2 text-center text-xs ${
isOccupied ? 'bg-red-100' : 'bg-green-50'
}`}
title={
reservation
? `${reservation.user_name} - ${reservation.purpose}\n${reservation.start_time} - ${reservation.end_time}`
: 'Disponible'
}
>
{isOccupied ? (
<div className="text-red-700 font-medium">
<XMarkIcon className="w-4 h-4 mx-auto" />
<div className="text-[10px] mt-1 truncate">
{reservation.user_name}
</div>
</div>
) : (
<div className="text-green-600">
<CheckIcon className="w-4 h-4 mx-auto" />
</div>
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
{/* Légende */}
<div className="mt-6 flex gap-6 justify-center">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-50 border border-gray-300 flex items-center justify-center text-green-600">
<CheckIcon className="w-4 h-4" />
</div>
<span className="text-sm text-gray-700">Disponible</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-red-100 border border-gray-300 flex items-center justify-center text-red-700">
<XMarkIcon className="w-4 h-4" />
</div>
<span className="text-sm text-gray-700">Réservé</span>
</div>
</div>
{/* Liste des réservations du jour */}
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4 text-gray-800">
Réservations du {new Date(selectedDate).toLocaleDateString('fr-FR')}
</h3>
{reservations.filter(r => r.reservation_date === selectedDate).length === 0 ? (
<p className="text-gray-500 text-center py-4">Aucune réservation pour cette date</p>
) : (
<div className="space-y-3">
{reservations
.filter(r => r.reservation_date === selectedDate)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map((reservation) => (
<div
key={reservation.id}
className="border border-gray-200 rounded-lg p-3 bg-gray-50"
>
<div className="flex justify-between items-start">
<div>
<span className="font-semibold text-indigo-900">{reservation.room_name}</span>
<span className="mx-2"></span>
<span className="text-gray-700">{reservation.start_time} - {reservation.end_time}</span>
</div>
<div className="text-right text-sm">
<div className="font-medium text-gray-900">{reservation.user_name}</div>
<div className="text-gray-600">{reservation.purpose}</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Onglet Réservation */}
{activeTab === 'rooms' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Liste des salles */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">
Nos Salles Disponibles
</h2>
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{rooms.map((room) => (
<div
key={room.id}
onClick={() => setSelectedRoom(room.id)}
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
selectedRoom === room.id
? 'border-indigo-600 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900">
{room.name}
</h3>
<p className="text-sm text-gray-600 mt-1 flex items-center gap-1">
<UserGroupIcon className="w-4 h-4" />
Capacité: {room.capacity} personnes
</p>
{room.equipment && (
<p className="text-sm text-gray-600 mt-1 flex items-center gap-1">
<WrenchScrewdriverIcon className="w-4 h-4" />
{room.equipment}
</p>
)}
{room.description && (
<p className="text-sm text-gray-500 mt-2">
{room.description}
</p>
)}
</div>
{selectedRoom === room.id && (
<div className="ml-2">
<span className="inline-block bg-indigo-600 text-white rounded-full p-2">
<CheckIcon className="w-4 h-4" />
</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Formulaire de réservation */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">
Formulaire de Réservation
</h2>
{!selectedRoom ? (
<div className="text-center py-12 text-gray-500">
<BuildingOffice2Icon className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<p className="text-lg">Sélectionnez une salle pour continuer</p>
</div>
) : (
<form onSubmit={handleReservation} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Votre nom *
</label>
<input
type="text"
value={formData.user_name}
onChange={(e) => setFormData({ ...formData, user_name: e.target.value })}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Jean Dupont"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email (optionnel)
</label>
<input
type="email"
value={formData.user_email}
onChange={(e) => setFormData({ ...formData, user_email: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="jean.dupont@expernet.fr"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date *
</label>
<input
type="date"
value={formData.reservation_date}
onChange={(e) => setFormData({ ...formData, reservation_date: e.target.value })}
required
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Heure de début *
</label>
<input
type="time"
value={formData.start_time}
onChange={(e) => setFormData({ ...formData, start_time: e.target.value })}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Heure de fin *
</label>
<input
type="time"
value={formData.end_time}
onChange={(e) => setFormData({ ...formData, end_time: e.target.value })}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Objet de la réservation *
</label>
<textarea
value={formData.purpose}
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
required
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Formation Python, Réunion d'équipe, etc."
/>
</div>
{/* Vérification de disponibilité */}
{selectedRoom && formData.reservation_date && formData.start_time && formData.end_time && (
<div className={`p-3 rounded-lg ${
isSlotAvailable(selectedRoom, formData.reservation_date, formData.start_time, formData.end_time)
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
{isSlotAvailable(selectedRoom, formData.reservation_date, formData.start_time, formData.end_time) ? (
<div className="flex items-center gap-2 text-green-700">
<CheckCircleIcon className="w-6 h-6" />
<span className="font-medium">Ce créneau est disponible !</span>
</div>
) : (
<div className="flex items-center gap-2 text-red-700">
<XCircleIcon className="w-6 h-6" />
<span className="font-medium">Ce créneau est déjà réservé. Choisissez un autre horaire.</span>
</div>
)}
</div>
)}
<button
type="submit"
disabled={loading || !!(selectedRoom && formData.reservation_date && formData.start_time && formData.end_time && !isSlotAvailable(selectedRoom, formData.reservation_date, formData.start_time, formData.end_time))}
className="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition-colors disabled:bg-gray-400 font-medium text-lg flex items-center justify-center gap-2"
>
{loading ? (
<>
<ClockIcon className="w-5 h-5 animate-spin" />
Réservation en cours...
</>
) : (
<>
<CheckCircleIcon className="w-5 h-5" />
Confirmer la réservation
</>
)}
</button>
</form>
)}
</div>
</div>
)}
{/* Onglet Liste des réservations */}
{activeTab === 'reservations' && (
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Liste des Réservations
</h2>
{reservations.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p className="text-lg">Aucune réservation pour le moment</p>
</div>
) : (
<div className="space-y-4">
{reservations.map((reservation) => (
<div
key={reservation.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-bold text-indigo-900">
{reservation.room_name}
</h3>
<span className="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
{reservation.user_name}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600">
<p className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
{new Date(reservation.reservation_date).toLocaleDateString('fr-FR')}
</p>
<p className="flex items-center gap-1">
<ClockIcon className="w-4 h-4" />
{reservation.start_time} - {reservation.end_time}
</p>
{reservation.user_email && (
<p className="col-span-2 flex items-center gap-1">
<EnvelopeIcon className="w-4 h-4" />
{reservation.user_email}
</p>
)}
<p className="col-span-2 flex items-center gap-1">
<DocumentTextIcon className="w-4 h-4" />
{reservation.purpose}
</p>
</div>
</div>
<button
onClick={() => handleDeleteReservation(reservation.id)}
className="ml-4 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors font-medium flex items-center gap-2"
>
<TrashIcon className="w-5 h-5" />
Annuler
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</main>
)
}