Added few functions

This commit is contained in:
2026-02-16 14:43:02 +01:00
parent 0c908a21ac
commit 1ec4c935c9
31 changed files with 352 additions and 35 deletions

View File

@@ -39,6 +39,9 @@ export async function GET(
email: true,
},
},
participations: {
select: { id: true },
},
},
});

View File

@@ -35,6 +35,9 @@ export async function GET(request: NextRequest) {
telephone: true,
},
},
participations: {
select: { id: true },
},
},
orderBy: {
updatedAt: 'desc' as const,

View File

@@ -59,6 +59,9 @@ export async function GET(request: NextRequest) {
telephone: true,
},
},
participations: {
select: { id: true },
},
},
orderBy,
take: limit ? parseInt(limit) : undefined,

View File

@@ -19,13 +19,6 @@ export default async function CalendrierPage() {
return (
<DashboardLayout user={user}>
<div className="p-4 sm:p-6 lg:p-8">
<h1 className="text-2xl sm:text-3xl font-semibold text-cblack mb-2">
Calendrier
</h1>
<p className="text-xs sm:text-sm text-cgray mb-6 sm:mb-8">
Gestion des trajets et planning des chauffeurs
</p>
<CalendrierPageContent />
</div>
</DashboardLayout>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import AlertModal from './AlertModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface Adherent {
id: string;
@@ -26,6 +27,7 @@ interface AdherentFormProps {
}
export default function AdherentForm({ adherent, onClose }: AdherentFormProps) {
useBodyScrollLock(true);
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [alertModal, setAlertModal] = useState<{

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
import AdherentForm from './AdherentForm';
import ConfirmModal from './ConfirmModal';
@@ -41,6 +42,7 @@ export default function AdherentsTable() {
show: boolean;
id: string | null;
} | null>(null);
useBodyScrollLock(showImportModal || !!(resultModal?.show) || !!viewingAdherent);
const fetchAdherents = async (searchTerm: string = '') => {
setLoading(true);

View File

@@ -1,5 +1,7 @@
'use client';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface AlertModalProps {
isOpen: boolean;
type: 'success' | 'error' | 'info' | 'warning';
@@ -15,6 +17,7 @@ export default function AlertModal({
message,
onClose,
}: AlertModalProps) {
useBodyScrollLock(isOpen);
if (!isOpen) return null;
const getStyles = () => {

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
import ConfirmModal from './ConfirmModal';
import { getParticipationRef } from '@/lib/participation-ref';
interface Trajet {
id: string;
@@ -12,6 +13,7 @@ interface Trajet {
commentaire?: string | null;
statut: string;
archived: boolean;
participations?: { id: string }[];
adherent: {
id: string;
nom: string;
@@ -117,14 +119,21 @@ export default function ArchivesTrajets() {
return `${prenom.charAt(0)}${nom.charAt(0)}`.toUpperCase();
};
const participationRef = (t: Trajet) => {
const p = t.participations?.[0];
return p ? getParticipationRef(p.id) : null;
};
const filteredTrajets = trajets.filter((trajet) => {
const ref = participationRef(trajet);
const searchLower = searchTerm.toLowerCase();
return (
trajet.adherent.nom.toLowerCase().includes(searchLower) ||
trajet.adherent.prenom.toLowerCase().includes(searchLower) ||
trajet.adresseDepart.toLowerCase().includes(searchLower) ||
trajet.adresseArrivee.toLowerCase().includes(searchLower) ||
(trajet.chauffeur && `${trajet.chauffeur.prenom} ${trajet.chauffeur.nom}`.toLowerCase().includes(searchLower))
(trajet.chauffeur && `${trajet.chauffeur.prenom} ${trajet.chauffeur.nom}`.toLowerCase().includes(searchLower)) ||
(ref && ref.toLowerCase().includes(searchLower))
);
});
@@ -144,7 +153,7 @@ export default function ArchivesTrajets() {
<div className="flex-1 relative">
<input
type="text"
placeholder="Rechercher dans les archives..."
placeholder="Rechercher (adhérent, adresse, référence PART-…)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 md:px-4 py-2 md:py-2.5 pl-9 md:pl-10 text-sm md:text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-lblue focus:border-transparent"
@@ -188,6 +197,11 @@ export default function ArchivesTrajets() {
{formatDate(trajet.date)} à {formatTime(trajet.date)}
</div>
</div>
{participationRef(trajet) && (
<span className="px-1.5 py-0.5 text-xs font-mono font-medium rounded bg-lblue/10 text-lblue flex-shrink-0" title="Référence de prescription">
{participationRef(trajet)}
</span>
)}
<span className={`px-2 py-1 text-xs font-medium rounded border ${getStatutColor(trajet.statut)} flex-shrink-0`}>
{trajet.statut}
</span>

View File

@@ -3,6 +3,8 @@
import React, { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
import Link from 'next/link';
import { getParticipationRef } from '@/lib/participation-ref';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface HistoriqueItem {
id: string;
@@ -41,6 +43,7 @@ export default function BudgetContent() {
const [rectifierBudget, setRectifierBudget] = useState('');
const [rectifierAjustement, setRectifierAjustement] = useState('');
const [expandedPrescripteur, setExpandedPrescripteur] = useState<string | null>(null);
useBodyScrollLock(!!addModalPrescripteur || !!rectifierModalPrescripteur);
useEffect(() => {
fetchBudgets();
@@ -332,6 +335,7 @@ export default function BudgetContent() {
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Date</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Référence</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Adhérent</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Montant</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 w-24">Lien</th>
@@ -341,6 +345,11 @@ export default function BudgetContent() {
{item.historique.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 text-gray-700">{formatDate(h.date)}</td>
<td className="px-4 py-2">
<span className="font-mono text-xs font-medium text-lblue" title="Référence de prescription">
{getParticipationRef(h.id)}
</span>
</td>
<td className="px-4 py-2 text-gray-700">{h.adherentNom}</td>
<td className="px-4 py-2 text-right text-orange-600 font-medium">
{formatEuro(h.montant)}
@@ -455,6 +464,7 @@ export default function BudgetContent() {
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900 truncate">{h.adherentNom}</p>
<p className="text-xs text-gray-500">{formatDate(h.date)}</p>
<p className="text-xs font-mono text-lblue mt-0.5">{getParticipationRef(h.id)}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-orange-600 font-medium">{formatEuro(h.montant)}</span>

View File

@@ -3,9 +3,11 @@
import { useState } from 'react';
import CalendrierTrajets from './CalendrierTrajets';
import ListeTrajets from './ListeTrajets';
import TrajetForm from './TrajetForm';
export default function CalendrierPageContent() {
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [showTrajetForm, setShowTrajetForm] = useState(false);
const handleTrajetCreated = () => {
setRefreshTrigger((prev) => prev + 1);
@@ -13,6 +15,27 @@ export default function CalendrierPageContent() {
return (
<div className="flex flex-col gap-6 sm:gap-8">
{/* En-tête avec titre et bouton */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-2">
<div>
<h1 className="text-2xl sm:text-3xl font-semibold text-cblack">
Calendrier
</h1>
<p className="text-xs sm:text-sm text-cgray mt-1">
Gestion des trajets et planning des chauffeurs
</p>
</div>
<button
onClick={() => setShowTrajetForm(true)}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-lgreen text-white text-sm font-medium rounded-lg hover:bg-dgreen transition-colors self-start sm:self-center shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau trajet
</button>
</div>
{/* Calendrier en haut */}
<div>
<CalendrierTrajets refreshTrigger={refreshTrigger} />
@@ -20,8 +43,19 @@ export default function CalendrierPageContent() {
{/* Liste des trajets en bas, triable par période */}
<div>
<ListeTrajets onTrajetCreated={handleTrajetCreated} />
<ListeTrajets onTrajetCreated={handleTrajetCreated} hideNewTrajetButton />
</div>
{/* Modal formulaire trajet */}
{showTrajetForm && (
<TrajetForm
onClose={() => setShowTrajetForm(false)}
onSuccess={() => {
handleTrajetCreated();
setShowTrajetForm(false);
}}
/>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, us
import { CSS } from '@dnd-kit/utilities';
import TrajetDetailModal from './TrajetDetailModal';
import { useNotification } from './NotificationProvider';
import { getParticipationRef } from '@/lib/participation-ref';
interface Trajet {
id: string;
@@ -13,6 +14,7 @@ interface Trajet {
adresseArrivee: string;
commentaire?: string | null;
statut: string;
participations?: { id: string }[];
adherent: {
id: string;
nom: string;
@@ -518,6 +520,11 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP
<span className="text-xs sm:text-sm font-semibold text-gray-900">
{trajet.adherent.prenom} {trajet.adherent.nom}
</span>
{trajet.participations?.[0] && (
<span className="px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-mono font-medium rounded bg-lblue/10 text-lblue" title="Référence de prescription">
{getParticipationRef(trajet.participations[0].id)}
</span>
)}
<span className="px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-medium rounded bg-lblue/10 text-lblue">
{formatTime(trajet.date)}
</span>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import ChauffeurForm from './ChauffeurForm';
import ConfirmModal from './ConfirmModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface Chauffeur {
id: string;
@@ -39,6 +40,7 @@ export default function ChauffeursTable() {
show: boolean;
id: string | null;
} | null>(null);
useBodyScrollLock(showImportModal || !!(resultModal?.show) || !!viewingChauffeur);
const fetchChauffeurs = async (searchTerm: string = '') => {
setLoading(true);

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { useNotification } from './NotificationProvider';
import { AVAILABLE_PAGES } from '@/lib/pages';
import ConfirmModal from './ConfirmModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface AdherentOption {
id: string;
@@ -200,6 +201,8 @@ export default function ConfigurationContent() {
roleId: string | null;
} | null>(null);
useBodyScrollLock(isMobile);
const fetchOptions = useCallback(async () => {
setLoading(true);
try {
@@ -373,6 +376,7 @@ export default function ConfigurationContent() {
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null);
useBodyScrollLock(showPasswordModal && !!newPassword);
useEffect(() => {
fetchUsers();
@@ -688,6 +692,7 @@ export default function ConfigurationContent() {
description: '',
pageRoutes: [] as string[],
});
useBodyScrollLock(showRoleForm);
useEffect(() => {
fetchRoles();

View File

@@ -1,5 +1,7 @@
'use client';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
@@ -21,6 +23,7 @@ export default function ConfirmModal({
onConfirm,
onCancel,
}: ConfirmModalProps) {
useBodyScrollLock(isOpen);
if (!isOpen) return null;
const getConfirmButtonStyle = () => {

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import TrajetForm from './TrajetForm';
import AdherentForm from './AdherentForm';
import TrajetDetailModal from './TrajetDetailModal';
import { getParticipationRef } from '@/lib/participation-ref';
interface Stats {
participationsMois: {
@@ -32,6 +33,7 @@ interface Trajet {
adresseArrivee: string;
commentaire?: string | null;
statut: string;
participations?: { id: string }[];
adherent: {
id: string;
nom: string;
@@ -361,6 +363,11 @@ export default function DashboardContent({ userName }: DashboardContentProps) {
{trajet.adherent.prenom} {trajet.adherent.nom}
</h3>
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1">
{trajet.participations?.[0] && (
<span className="px-1.5 py-0.5 text-[10px] sm:text-xs font-mono font-medium rounded bg-lblue/10 text-lblue" title="Référence de prescription">
{getParticipationRef(trajet.participations[0].id)}
</span>
)}
<span className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
<svg className="w-2.5 h-2.5 sm:w-3 sm:h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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" />

View File

@@ -3,6 +3,7 @@
import { useRouter, usePathname } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
import Link from 'next/link';
import useSWR from 'swr';
@@ -34,6 +35,7 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
const [showNotifications, setShowNotifications] = useState(false);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
useBodyScrollLock(sidebarOpen);
// Récupérer les conversations pour compter les messages non lus
const { data: conversations } = useSWR<Array<{ unreadCount: number }>>(

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import useSWR from 'swr';
import AlertModal from './AlertModal';
import ConfirmModal from './ConfirmModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface User {
id: string;
@@ -38,6 +39,7 @@ export default function GroupSettingsModal({
onClose,
onUpdate,
}: GroupSettingsModalProps) {
useBodyScrollLock(true);
const [groupName, setGroupName] = useState(conversation.name || '');
const [search, setSearch] = useState('');
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import TrajetForm from './TrajetForm';
import { getParticipationRef } from '@/lib/participation-ref';
interface Trajet {
id: string;
@@ -10,6 +11,7 @@ interface Trajet {
adresseArrivee: string;
commentaire?: string | null;
statut: string;
participations?: { id: string }[];
adherent: {
id: string;
nom: string;
@@ -30,9 +32,10 @@ type FilterPeriod = 'derniers' | 'jour' | 'mois' | 'an' | 'personnalise';
interface ListeTrajetsProps {
onTrajetCreated?: () => void;
compact?: boolean;
hideNewTrajetButton?: boolean;
}
export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsProps) {
export default function ListeTrajets({ onTrajetCreated, compact, hideNewTrajetButton }: ListeTrajetsProps) {
const [trajets, setTrajets] = useState<Trajet[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -119,7 +122,13 @@ export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsP
}
};
const participationRef = (t: Trajet) => {
const p = t.participations?.[0];
return p ? getParticipationRef(p.id) : null;
};
const filteredTrajets = trajets.filter((trajet) => {
const ref = participationRef(trajet);
const matchesSearch =
!search ||
trajet.adherent.nom.toLowerCase().includes(search.toLowerCase()) ||
@@ -129,7 +138,8 @@ export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsP
(trajet.chauffeur &&
`${trajet.chauffeur.prenom} ${trajet.chauffeur.nom}`
.toLowerCase()
.includes(search.toLowerCase()));
.includes(search.toLowerCase())) ||
(ref && ref.toLowerCase().includes(search.toLowerCase()));
const matchesStatut = !filterStatut || trajet.statut === filterStatut;
@@ -180,7 +190,7 @@ export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsP
</div>
<input
type="text"
placeholder="Rechercher..."
placeholder="Rechercher (adhérent, adresse, référence PART-…)..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-lblue focus:border-lblue transition-all"
@@ -272,15 +282,17 @@ export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsP
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => setShowTrajetForm(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-lgreen text-white text-xs font-medium rounded-lg hover:bg-dgreen transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau trajet
</button>
{!hideNewTrajetButton && (
<button
onClick={() => setShowTrajetForm(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-lgreen text-white text-xs font-medium rounded-lg hover:bg-dgreen transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau trajet
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
@@ -374,6 +386,11 @@ export default function ListeTrajets({ onTrajetCreated, compact }: ListeTrajetsP
{trajet.adherent.prenom} {trajet.adherent.nom}
</h3>
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1">
{participationRef(trajet) && (
<span className="px-1.5 py-0.5 text-[10px] sm:text-xs font-mono font-medium rounded bg-lblue/10 text-lblue" title="Référence de prescription">
{participationRef(trajet)}
</span>
)}
<span className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
<svg className="w-2.5 h-2.5 sm:w-3 sm:h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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" />

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
import ChatWindow from './ChatWindow';
import NewConversationModal from './NewConversationModal';
import GroupSettingsModal from './GroupSettingsModal';
@@ -46,6 +47,7 @@ export default function Messagerie() {
const [showNewConversation, setShowNewConversation] = useState(false);
const [showGroupSettings, setShowGroupSettings] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
useBodyScrollLock(showSidebar);
const { data: conversations, error, mutate } = useSWR<Conversation[]>(
'/api/conversations',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import AlertModal from './AlertModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface User {
id: string;
@@ -21,6 +22,7 @@ export default function NewConversationModal({
onClose,
onConversationCreated,
}: NewConversationModalProps) {
useBodyScrollLock(true);
const [search, setSearch] = useState('');
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [conversationType, setConversationType] = useState<'direct' | 'group'>('direct');

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface Participation {
id: string;
@@ -26,6 +27,7 @@ export default function ParticipationEditModal({
onSuccess,
}: ParticipationEditModalProps) {
const { showNotification } = useNotification();
useBodyScrollLock(true);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
destinataireEmail: '',

View File

@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
import { useNotification } from './NotificationProvider';
import ConfirmModal from './ConfirmModal';
import ParticipationEditModal from './ParticipationEditModal';
import { getParticipationRef } from '@/lib/participation-ref';
interface Participation {
id: string;
@@ -59,9 +60,6 @@ const STATUT_CONFIG: Record<string, { label: string; className: string; dot: str
},
};
function getRefNum(id: string) {
return `PART-${id.slice(-8).toUpperCase()}`;
}
export default function ParticipationFinanciereList() {
const { showNotification } = useNotification();
@@ -204,7 +202,7 @@ export default function ParticipationFinanciereList() {
const filteredParticipations = participations.filter((p) => {
const search = searchTerm.toLowerCase();
const ref = getRefNum(p.id).toLowerCase();
const ref = getParticipationRef(p.id).toLowerCase();
const chauffeurName = p.trajet.chauffeur
? `${p.trajet.chauffeur.prenom} ${p.trajet.chauffeur.nom}`.toLowerCase()
: '';
@@ -500,7 +498,7 @@ export default function ParticipationFinanciereList() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredParticipations.map((p) => {
const ref = getRefNum(p.id);
const ref = getParticipationRef(p.id);
const chauffeur = p.trajet.chauffeur;
const montant = p.montant != null ? `${p.montant.toFixed(2).replace('.', ',')}` : '—';
const statutConfig = STATUT_CONFIG[p.statut] || STATUT_CONFIG.en_attente;
@@ -619,7 +617,7 @@ export default function ParticipationFinanciereList() {
{/* Vue mobile - Cartes */}
<div className="md:hidden divide-y divide-gray-200">
{filteredParticipations.map((p) => {
const ref = getRefNum(p.id);
const ref = getParticipationRef(p.id);
const chauffeur = p.trajet.chauffeur;
const montant = p.montant != null ? `${p.montant.toFixed(2).replace('.', ',')}` : '—';
const statutConfig = STATUT_CONFIG[p.statut] || STATUT_CONFIG.en_attente;

View File

@@ -3,6 +3,8 @@
import { useState } from 'react';
import dynamic from 'next/dynamic';
import TrajetForm from './TrajetForm';
import { getParticipationRef } from '@/lib/participation-ref';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
import ValidationModal from './ValidationModal';
import ConfirmModal from './ConfirmModal';
import { useNotification } from './NotificationProvider';
@@ -25,6 +27,7 @@ interface Trajet {
commentaire?: string | null;
instructions?: string | null;
statut: string;
participations?: { id: string }[];
adherent: {
id: string;
nom: string;
@@ -50,6 +53,7 @@ interface TrajetDetailModalProps {
export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetDetailModalProps) {
const { showNotification } = useNotification();
useBodyScrollLock(true);
const [showEditForm, setShowEditForm] = useState(false);
const [showValidationModal, setShowValidationModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@@ -219,6 +223,11 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900">Détails du trajet</h2>
{trajet.participations?.[0] && (
<span className="px-2 py-1 text-xs font-mono font-medium rounded-lg bg-lblue/10 text-lblue border border-lblue/20 flex-shrink-0" title="Référence de prescription">
{getParticipationRef(trajet.participations[0].id)}
</span>
)}
<span className={`px-2.5 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-semibold rounded-lg border flex-shrink-0 ${getStatutColor(trajet.statut)}`}>
{trajet.statut}
</span>

View File

@@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from 'react';
import TrajetMap from './TrajetMap';
import AddressAutocomplete from './AddressAutocomplete';
import { useNotification } from './NotificationProvider';
import { calculerDureeTrajet } from '@/lib/trajet-duree';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface Adherent {
id: string;
@@ -27,6 +29,8 @@ interface Chauffeur {
prenom: string;
telephone: string;
email: string;
heuresRestantes?: number;
heuresContrat?: number;
}
interface TrajetFormProps {
@@ -47,6 +51,7 @@ interface TrajetFormProps {
export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetFormProps) {
const { showNotification } = useNotification();
useBodyScrollLock(true); // TrajetForm est toujours affiché en modal quand monté
const [loading, setLoading] = useState(false);
const [adherents, setAdherents] = useState<Adherent[]>([]);
const [chauffeurs, setChauffeurs] = useState<Chauffeur[]>([]);
@@ -56,6 +61,7 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
const [showChauffeurDropdown, setShowChauffeurDropdown] = useState(false);
const adherentDropdownRef = useRef<HTMLDivElement>(null);
const chauffeurDropdownRef = useRef<HTMLDivElement>(null);
const [dureeEstimee, setDureeEstimee] = useState<number | null>(null);
const [formData, setFormData] = useState({
adherentId: trajetToEdit?.adherentId || '',
@@ -83,6 +89,50 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
fetchChauffeurs();
}, []);
// Heures minimales requises pour ce trajet (arrondi au supérieur pour être conservateur)
const heuresRequerues = dureeEstimee != null ? Math.ceil(dureeEstimee) : 0;
// Calculer la durée du trajet quand les adresses sont remplies (pour filtrer les chauffeurs)
useEffect(() => {
if (!formData.adresseDepart?.trim() || !formData.adresseArrivee?.trim()) {
setDureeEstimee(null);
return;
}
let cancelled = false;
calculerDureeTrajet(formData.adresseDepart, formData.adresseArrivee).then((duree) => {
if (!cancelled && duree != null) {
setDureeEstimee(duree);
}
});
return () => {
cancelled = true;
};
}, [formData.adresseDepart, formData.adresseArrivee]);
// Si la durée estimée exclut le chauffeur sélectionné, le désélectionner
useEffect(() => {
if (heuresRequerues > 0 && formData.chauffeurId) {
const selectedChauffeur = chauffeurs.find((c) => c.id === formData.chauffeurId);
if (selectedChauffeur) {
const heuresDispo = selectedChauffeur.heuresRestantes ?? selectedChauffeur.heuresContrat ?? 35;
if (heuresDispo < heuresRequerues) {
setFormData((prev) => ({
...prev,
chauffeurId: '',
chauffeurNom: '',
chauffeurPrenom: '',
chauffeurTelephone: '',
}));
setSearchChauffeur('');
showNotification(
'warning',
`${selectedChauffeur.prenom} ${selectedChauffeur.nom} n'a que ${heuresDispo}h disponibles (trajet estimé ~${dureeEstimee}h)`
);
}
}
}
}, [heuresRequerues, dureeEstimee, formData.chauffeurId, chauffeurs]);
useEffect(() => {
// Si on modifie un trajet, charger les données de l'adhérent et du chauffeur
if (trajetToEdit) {
@@ -276,13 +326,20 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
a.telephone.includes(searchAdherent)
);
const filteredChauffeurs = chauffeurs.filter(
(c) =>
!searchChauffeur ||
`${c.prenom} ${c.nom}`.toLowerCase().includes(searchChauffeur.toLowerCase()) ||
c.email.toLowerCase().includes(searchChauffeur.toLowerCase()) ||
c.telephone.includes(searchChauffeur)
);
const filteredChauffeurs = chauffeurs
.filter((c) => {
// Exclure les chauffeurs qui n'ont pas assez d'heures restantes
if (heuresRequerues > 0) {
const heuresDispo = c.heuresRestantes ?? c.heuresContrat ?? 35;
if (heuresDispo < heuresRequerues) return false;
}
return (
!searchChauffeur ||
`${c.prenom} ${c.nom}`.toLowerCase().includes(searchChauffeur.toLowerCase()) ||
c.email.toLowerCase().includes(searchChauffeur.toLowerCase()) ||
c.telephone.includes(searchChauffeur)
);
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -460,6 +517,11 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
<div>
<label className="block text-sm font-semibold text-gray-900 mb-2">
Chauffeur
{dureeEstimee != null && (
<span className="ml-2 text-xs font-normal text-gray-500">
(trajet ~{dureeEstimee}h chauffeurs avec &lt;{heuresRequerues}h restantes masqués)
</span>
)}
</label>
<div className="relative" ref={chauffeurDropdownRef}>
<input

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import AlertModal from './AlertModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface UniversPro {
id: string;
@@ -19,6 +20,7 @@ interface UniversProFormProps {
}
export default function UniversProForm({ contact, onClose }: UniversProFormProps) {
useBodyScrollLock(true);
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [alertModal, setAlertModal] = useState<{

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
import UniversProForm from './UniversProForm';
import ConfirmModal from './ConfirmModal';
@@ -34,6 +35,7 @@ export default function UniversProTable() {
show: boolean;
id: string | null;
} | null>(null);
useBodyScrollLock(showImportModal || !!(resultModal?.show) || !!viewingContact);
const fetchContacts = async (searchTerm: string = '') => {
setLoading(true);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface Trajet {
id: string;
@@ -23,6 +24,7 @@ interface ValidationModalProps {
export default function ValidationModal({ trajet, onClose, onSuccess }: ValidationModalProps) {
const { showNotification } = useNotification();
useBodyScrollLock(true);
const [loading, setLoading] = useState(false);
const [dureeTrajet, setDureeTrajet] = useState<number | null>(null);

45
lib/body-scroll-lock.ts Normal file
View File

@@ -0,0 +1,45 @@
'use client';
/**
* Gère le verrouillage du scroll du body quand des modales sont ouvertes.
* Gère plusieurs modales empilées via un compteur.
*/
let lockCount = 0;
let savedScrollY = 0;
export function lockBodyScroll() {
lockCount++;
if (lockCount === 1) {
savedScrollY = window.scrollY;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${savedScrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
}
}
export function unlockBodyScroll() {
if (lockCount > 0) lockCount--;
if (lockCount === 0) {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, savedScrollY);
}
}
import { useEffect } from 'react';
export function useBodyScrollLock(isOpen: boolean) {
useEffect(() => {
if (isOpen) {
lockBodyScroll();
return () => unlockBodyScroll();
}
}, [isOpen]);
}

7
lib/participation-ref.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Génère la référence de prescription (ex: PART-XXXXXXXX)
* utilisée dans les participations financières et affichée sur les trajets.
*/
export function getParticipationRef(participationId: string): string {
return `PART-${participationId.slice(-8).toUpperCase()}`;
}

72
lib/trajet-duree.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Calcule la durée estimée d'un trajet en heures à partir des adresses.
* Utilise Nominatim pour géocoder et la formule de Haversine pour la distance.
*/
export async function calculerDureeTrajet(
adresseDepart: string,
adresseArrivee: string
): Promise<number | null> {
if (!adresseDepart?.trim() || !adresseArrivee?.trim()) {
return null;
}
try {
const departResponse = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(adresseDepart.trim())}&limit=1&countrycodes=fr`,
{
headers: {
'User-Agent': 'MAD Platform',
'Accept-Language': 'fr-FR,fr;q=0.9',
},
}
);
if (!departResponse.ok) return null;
await new Promise((resolve) => setTimeout(resolve, 1000));
const arriveeResponse = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(adresseArrivee.trim())}&limit=1&countrycodes=fr`,
{
headers: {
'User-Agent': 'MAD Platform',
'Accept-Language': 'fr-FR,fr;q=0.9',
},
}
);
if (!arriveeResponse.ok) return null;
const [departData, arriveeData] = await Promise.all([
departResponse.json(),
arriveeResponse.json(),
]);
if (!departData?.length || !arriveeData?.length) return null;
const lat1 = parseFloat(departData[0].lat);
const lon1 = parseFloat(departData[0].lon);
const lat2 = parseFloat(arriveeData[0].lat);
const lon2 = parseFloat(arriveeData[0].lon);
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;
const distanceWithDetour = distance * 1.3;
const vitesseMoyenne = 50; // km/h
const dureeEnHeures = distanceWithDetour / vitesseMoyenne;
return Math.round(dureeEnHeures * 10) / 10;
} catch {
return null;
}
}

Binary file not shown.