-
-
-
-
- {trajet.adherent.prenom} {trajet.adherent.nom}
-
-
- {formatTime(trajet.date)}
-
-
- {trajet.statut}
-
-
-
- Départ: {trajet.adresseDepart}
-
-
- Arrivée: {trajet.adresseArrivee}
-
- {trajet.chauffeur ? (
-
-
-
- Chauffeur: {trajet.chauffeur.prenom} {trajet.chauffeur.nom}
+ {selectedTrajets.map((trajet) => {
+ const getStatutColor = (statut: string) => {
+ switch (statut) {
+ case 'Validé':
+ return 'bg-purple-100 text-purple-700 border-purple-200';
+ case 'Terminé':
+ return 'bg-green-100 text-green-700 border-green-200';
+ case 'En cours':
+ return 'bg-blue-100 text-blue-700 border-blue-200';
+ case 'Annulé':
+ return 'bg-red-100 text-red-700 border-red-200';
+ default:
+ return 'bg-gray-100 text-gray-700 border-gray-200';
+ }
+ };
+
+ return (
+
+ );
+ })}
)}
@@ -308,6 +567,18 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP
)}
>
)}
+
+ {/* Modal de détails du trajet */}
+ {selectedTrajet && (
+
setSelectedTrajet(null)}
+ onUpdate={() => {
+ fetchTrajets();
+ setSelectedTrajet(null);
+ }}
+ />
+ )}
);
}
diff --git a/components/TrajetDetailModal.tsx b/components/TrajetDetailModal.tsx
new file mode 100644
index 0000000..48be88a
--- /dev/null
+++ b/components/TrajetDetailModal.tsx
@@ -0,0 +1,316 @@
+'use client';
+
+import { useState } from 'react';
+import TrajetForm from './TrajetForm';
+import ValidationModal from './ValidationModal';
+
+interface Trajet {
+ id: string;
+ date: string;
+ adresseDepart: string;
+ adresseArrivee: string;
+ commentaire?: string | null;
+ statut: string;
+ adherent: {
+ id: string;
+ nom: string;
+ prenom: string;
+ telephone: string;
+ email: string;
+ };
+ chauffeur?: {
+ id: string;
+ nom: string;
+ prenom: string;
+ telephone: string;
+ } | null;
+}
+
+interface TrajetDetailModalProps {
+ trajet: Trajet;
+ onClose: () => void;
+ onUpdate: () => void;
+}
+
+export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetDetailModalProps) {
+ const [showEditForm, setShowEditForm] = useState(false);
+ const [showValidationModal, setShowValidationModal] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('fr-FR', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+ };
+
+ const formatTime = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
+ };
+
+ const handleCancel = async () => {
+ if (!confirm('Êtes-vous sûr de vouloir annuler ce trajet ?')) {
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/trajets/${trajet.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ statut: 'Annulé',
+ }),
+ });
+
+ if (response.ok) {
+ onUpdate();
+ onClose();
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Erreur lors de l\'annulation du trajet');
+ }
+ } catch (error) {
+ console.error('Erreur lors de l\'annulation:', error);
+ alert('Erreur lors de l\'annulation du trajet');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getInitials = (nom: string, prenom: string) => {
+ return `${prenom.charAt(0)}${nom.charAt(0)}`.toUpperCase();
+ };
+
+ const getStatutColor = (statut: string) => {
+ switch (statut) {
+ case 'Terminé':
+ return 'bg-green-100 text-green-700 border-green-200';
+ case 'En cours':
+ return 'bg-blue-100 text-blue-700 border-blue-200';
+ case 'Annulé':
+ return 'bg-red-100 text-red-700 border-red-200';
+ case 'Validé':
+ return 'bg-purple-100 text-purple-700 border-purple-200';
+ default:
+ return 'bg-gray-100 text-gray-700 border-gray-200';
+ }
+ };
+
+ if (showEditForm) {
+ return (
+
setShowEditForm(false)}
+ onSuccess={() => {
+ onUpdate();
+ setShowEditForm(false);
+ onClose();
+ }}
+ trajetToEdit={trajet}
+ />
+ );
+ }
+
+ if (showValidationModal) {
+ return (
+ setShowValidationModal(false)}
+ onSuccess={() => {
+ onUpdate();
+ setShowValidationModal(false);
+ onClose();
+ }}
+ />
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Détails du trajet
+
Informations complètes du trajet
+
+
+
+
+
+ {/* Content */}
+
+
+ {/* Statut */}
+
+ Statut
+
+ {trajet.statut}
+
+
+
+ {/* Adhérent */}
+
+
+
+
+ {getInitials(trajet.adherent.nom, trajet.adherent.prenom)}
+
+
+
+ {trajet.adherent.prenom} {trajet.adherent.nom}
+
+
{trajet.adherent.email}
+
{trajet.adherent.telephone}
+
+
+
+
+ {/* Chauffeur */}
+ {trajet.chauffeur ? (
+
+
+
+
+ {getInitials(trajet.chauffeur.nom, trajet.chauffeur.prenom)}
+
+
+
+ {trajet.chauffeur.prenom} {trajet.chauffeur.nom}
+
+
{trajet.chauffeur.telephone}
+
+
+
+ ) : (
+
+
+
+
+
+
Aucun chauffeur assigné
+
+
+
+ )}
+
+ {/* Date et heure */}
+
+
+
+
+
+
{formatDate(trajet.date)}
+
+
+
+
+
+
+
{formatTime(trajet.date)}
+
+
+
+
+ {/* Adresses */}
+
+
+
+
+
+ A
+
+
{trajet.adresseDepart}
+
+
+
+
+
+
+
+
+
+ B
+
+
{trajet.adresseArrivee}
+
+
+
+
+ {/* Commentaire */}
+ {trajet.commentaire && (
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ {trajet.chauffeur && trajet.statut === 'Planifié' && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/TrajetForm.tsx b/components/TrajetForm.tsx
index 37e2255..eb9dd93 100644
--- a/components/TrajetForm.tsx
+++ b/components/TrajetForm.tsx
@@ -24,9 +24,19 @@ interface Chauffeur {
interface TrajetFormProps {
onClose: () => void;
onSuccess: () => void;
+ trajetToEdit?: {
+ id: string;
+ date: string;
+ adresseDepart: string;
+ adresseArrivee: string;
+ commentaire?: string | null;
+ statut: string;
+ adherentId: string;
+ chauffeurId?: string | null;
+ };
}
-export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
+export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetFormProps) {
const [loading, setLoading] = useState(false);
const [adherents, setAdherents] = useState([]);
const [chauffeurs, setChauffeurs] = useState([]);
@@ -38,20 +48,20 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
const chauffeurDropdownRef = useRef(null);
const [formData, setFormData] = useState({
- adherentId: '',
+ adherentId: trajetToEdit?.adherentId || '',
adherentNom: '',
adherentPrenom: '',
adherentAdresse: '',
adherentTelephone: '',
- chauffeurId: '',
+ chauffeurId: trajetToEdit?.chauffeurId || '',
chauffeurNom: '',
chauffeurPrenom: '',
chauffeurTelephone: '',
- date: '',
- heure: '',
- adresseDepart: '',
- adresseArrivee: '',
- commentaire: '',
+ date: trajetToEdit ? new Date(trajetToEdit.date).toISOString().split('T')[0] : '',
+ heure: trajetToEdit ? new Date(trajetToEdit.date).toTimeString().slice(0, 5) : '',
+ adresseDepart: trajetToEdit?.adresseDepart || '',
+ adresseArrivee: trajetToEdit?.adresseArrivee || '',
+ commentaire: trajetToEdit?.commentaire || '',
});
useEffect(() => {
@@ -59,6 +69,49 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
fetchChauffeurs();
}, []);
+ useEffect(() => {
+ // Si on modifie un trajet, charger les données de l'adhérent et du chauffeur
+ if (trajetToEdit) {
+ if (trajetToEdit.adherentId) {
+ fetch(`/api/adherents/${trajetToEdit.adherentId}`)
+ .then(res => res.json())
+ .then(data => {
+ if (data) {
+ setFormData(prev => ({
+ ...prev,
+ adherentId: data.id,
+ adherentNom: data.nom,
+ adherentPrenom: data.prenom,
+ adherentAdresse: data.adresse,
+ adherentTelephone: data.telephone,
+ adresseDepart: data.adresse,
+ }));
+ setSearchAdherent(`${data.prenom} ${data.nom}`);
+ }
+ })
+ .catch(console.error);
+ }
+
+ if (trajetToEdit.chauffeurId) {
+ fetch(`/api/chauffeurs/${trajetToEdit.chauffeurId}`)
+ .then(res => res.json())
+ .then(data => {
+ if (data) {
+ setFormData(prev => ({
+ ...prev,
+ chauffeurId: data.id,
+ chauffeurNom: data.nom,
+ chauffeurPrenom: data.prenom,
+ chauffeurTelephone: data.telephone,
+ }));
+ setSearchChauffeur(`${data.prenom} ${data.nom}`);
+ }
+ })
+ .catch(console.error);
+ }
+ }
+ }, [trajetToEdit]);
+
// Fermer les dropdowns quand on clique en dehors
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -160,8 +213,11 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
? new Date(`${formData.date}T09:00`).toISOString()
: new Date().toISOString();
- const response = await fetch('/api/trajets', {
- method: 'POST',
+ const url = trajetToEdit ? `/api/trajets/${trajetToEdit.id}` : '/api/trajets';
+ const method = trajetToEdit ? 'PUT' : 'POST';
+
+ const response = await fetch(url, {
+ method,
headers: {
'Content-Type': 'application/json',
},
@@ -170,7 +226,7 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
adresseDepart: formData.adresseDepart,
adresseArrivee: formData.adresseArrivee,
commentaire: formData.commentaire || null,
- statut: 'Planifié',
+ statut: trajetToEdit?.statut || 'Planifié',
adherentId: formData.adherentId,
chauffeurId: formData.chauffeurId || null,
}),
@@ -181,11 +237,11 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
onClose();
} else {
const error = await response.json();
- alert(`Erreur: ${error.error || 'Erreur lors de la création du trajet'}`);
+ alert(`Erreur: ${error.error || `Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`}`);
}
} catch (error) {
- console.error('Erreur lors de la création du trajet:', error);
- alert('Erreur lors de la création du trajet');
+ console.error(`Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet:`, error);
+ alert(`Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`);
} finally {
setLoading(false);
}
@@ -202,8 +258,12 @@ export default function TrajetForm({ onClose, onSuccess }: TrajetFormProps) {
-
Nouveau trajet
-
Créez un nouveau trajet pour un adhérent
+
+ {trajetToEdit ? 'Modifier le trajet' : 'Nouveau trajet'}
+
+
+ {trajetToEdit ? 'Modifiez les informations du trajet' : 'Créez un nouveau trajet pour un adhérent'}
+
diff --git a/components/ValidationModal.tsx b/components/ValidationModal.tsx
new file mode 100644
index 0000000..b1d6a49
--- /dev/null
+++ b/components/ValidationModal.tsx
@@ -0,0 +1,259 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface Trajet {
+ id: string;
+ date: string;
+ adresseDepart: string;
+ adresseArrivee: string;
+ chauffeur?: {
+ id: string;
+ nom: string;
+ prenom: string;
+ } | null;
+}
+
+interface ValidationModalProps {
+ trajet: Trajet;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+export default function ValidationModal({ trajet, onClose, onSuccess }: ValidationModalProps) {
+ const [loading, setLoading] = useState(false);
+ const [dureeTrajet, setDureeTrajet] = useState
(null);
+
+ // Calculer la durée du trajet en heures
+ const calculateDuration = async () => {
+ if (!trajet.adresseDepart || !trajet.adresseArrivee) {
+ return;
+ }
+
+ try {
+ // Géocoder l'adresse de départ
+ const departResponse = await fetch(
+ `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(trajet.adresseDepart)}&limit=1&countrycodes=fr`,
+ {
+ headers: {
+ 'User-Agent': 'MAD Platform',
+ 'Accept-Language': 'fr-FR,fr;q=0.9',
+ },
+ }
+ );
+
+ if (!departResponse.ok) {
+ return;
+ }
+
+ // Attendre avant la deuxième requête
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ const arriveeResponse = await fetch(
+ `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(trajet.adresseArrivee)}&limit=1&countrycodes=fr`,
+ {
+ headers: {
+ 'User-Agent': 'MAD Platform',
+ 'Accept-Language': 'fr-FR,fr;q=0.9',
+ },
+ }
+ );
+
+ if (departResponse.ok && arriveeResponse.ok) {
+ const [departData, arriveeData] = await Promise.all([
+ departResponse.json(),
+ arriveeResponse.json(),
+ ]);
+
+ if (departData.length > 0 && arriveeData.length > 0) {
+ const lat1 = parseFloat(departData[0].lat);
+ const lon1 = parseFloat(departData[0].lon);
+ const lat2 = parseFloat(arriveeData[0].lat);
+ const lon2 = parseFloat(arriveeData[0].lon);
+
+ // Calcul de la distance (Haversine)
+ const R = 6371; // Rayon de la Terre en km
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ const distance = R * c; // Distance en km
+
+ // Estimation du temps : vitesse moyenne de 50 km/h en ville
+ // On multiplie par 1.3 pour tenir compte des détours
+ const distanceWithDetour = distance * 1.3;
+ const vitesseMoyenne = 50; // km/h
+ const dureeEnHeures = distanceWithDetour / vitesseMoyenne;
+
+ setDureeTrajet(Math.round(dureeEnHeures * 10) / 10); // Arrondir à 1 décimale
+ }
+ }
+ } catch (error) {
+ console.error('Erreur lors du calcul de la durée:', error);
+ }
+ };
+
+ useEffect(() => {
+ calculateDuration();
+ }, []);
+
+ const handleValidate = async () => {
+ if (!trajet.chauffeur || !dureeTrajet) {
+ alert('Impossible de calculer la durée du trajet');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/trajets/${trajet.id}/validate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ dureeHeures: dureeTrajet,
+ }),
+ });
+
+ if (response.ok) {
+ onSuccess();
+ onClose();
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Erreur lors de la validation du trajet');
+ }
+ } catch (error) {
+ console.error('Erreur lors de la validation:', error);
+ alert('Erreur lors de la validation du trajet');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Valider le trajet
+
Confirmer la validation du trajet
+
+
+
+
+
+ {/* Content */}
+
+
+
+
+
+
+
+ En validant ce trajet, les heures seront déduites du contrat du chauffeur.
+
+
+ Cette action ne peut pas être annulée.
+
+
+
+
+
+ {trajet.chauffeur && (
+
+
+
+
+ {trajet.chauffeur.prenom} {trajet.chauffeur.nom}
+
+
+
+ )}
+
+ {dureeTrajet !== null ? (
+
+
+
+
+ Temps estimé
+
+ {dureeTrajet.toFixed(1)}h
+
+
+
+
+ {dureeTrajet.toFixed(1)} heure{dureeTrajet >= 2 ? 's' : ''} sera{dureeTrajet < 2 ? '' : 'ont'} déduite{dureeTrajet < 2 ? '' : 's'} des heures disponibles du chauffeur.
+
+
+
+
+ ) : (
+
+
+
+
Calcul de la durée du trajet...
+
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index 9b9b3d5..2f2b695 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "platform",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^5.19.1",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^2.4.3",
@@ -55,6 +58,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
diff --git a/package.json b/package.json
index 1c8efa0..cd50a33 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,9 @@
"setup": "tsx scripts/setup.ts"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^5.19.1",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^2.4.3",
diff --git a/prisma/dev.db b/prisma/dev.db
index 519c9c8..34d5678 100644
Binary files a/prisma/dev.db and b/prisma/dev.db differ