Added few functions
This commit is contained in:
@@ -39,6 +39,9 @@ export async function GET(
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export async function GET(request: NextRequest) {
|
||||
telephone: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc' as const,
|
||||
|
||||
@@ -59,6 +59,9 @@ export async function GET(request: NextRequest) {
|
||||
telephone: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
take: limit ? parseInt(limit) : undefined,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }>>(
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <{heuresRequerues}h restantes masqués)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="relative" ref={chauffeurDropdownRef}>
|
||||
<input
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
45
lib/body-scroll-lock.ts
Normal 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
7
lib/participation-ref.ts
Normal 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
72
lib/trajet-duree.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
Reference in New Issue
Block a user