Compare commits
No commits in common. "26a3412658a7b8f524609ab5bf056f8ac54614d3" and "317de4876594267a93325c3966407b5979e9267c" have entirely different histories.
26a3412658
...
317de48765
@ -860,55 +860,4 @@ export class AdminController {
|
||||
total: organization.documents.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from a CSV booking (admin only)
|
||||
* Bypasses ownership and status restrictions
|
||||
*/
|
||||
@Delete('bookings/:bookingId/documents/:documentId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Delete document from CSV booking (Admin only)',
|
||||
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Document deleted successfully',
|
||||
})
|
||||
async deleteDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
||||
if (documentIndex === -1) {
|
||||
throw new NotFoundException(`Document ${documentId} not found`);
|
||||
}
|
||||
|
||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
||||
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
|
||||
if (ormBooking) {
|
||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
filePath: doc.filePath,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
uploadedAt: doc.uploadedAt,
|
||||
}));
|
||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||
}
|
||||
|
||||
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
||||
return { success: true, message: 'Document deleted successfully' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -489,7 +489,6 @@ export class CsvRatesAdminController {
|
||||
size: fileSize,
|
||||
uploadedAt: config.uploadedAt.toISOString(),
|
||||
rowCount: config.rowCount,
|
||||
companyEmail: config.metadata?.companyEmail ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -8,8 +8,6 @@ import {
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
@ -20,9 +18,7 @@ import {
|
||||
RefreshTokenDto,
|
||||
ForgotPasswordDto,
|
||||
ResetPasswordDto,
|
||||
ContactFormDto,
|
||||
} from '../dto/auth-login.dto';
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
@ -43,13 +39,10 @@ import { InvitationService } from '../services/invitation.service';
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
private readonly invitationService: InvitationService,
|
||||
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
||||
private readonly invitationService: InvitationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -223,78 +216,6 @@ export class AuthController {
|
||||
return { message: 'Logout successful' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact form — forwards message to contact@xpeditis.com
|
||||
*/
|
||||
@Public()
|
||||
@Post('contact')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Contact form',
|
||||
description: 'Send a contact message to the Xpeditis team.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Message sent successfully' })
|
||||
async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> {
|
||||
const subjectLabels: Record<string, string> = {
|
||||
demo: 'Demande de démonstration',
|
||||
pricing: 'Questions sur les tarifs',
|
||||
partnership: 'Partenariat',
|
||||
support: 'Support technique',
|
||||
press: 'Relations presse',
|
||||
careers: 'Recrutement',
|
||||
other: 'Autre',
|
||||
};
|
||||
|
||||
const subjectLabel = subjectLabels[dto.subject] || dto.subject;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #10183A; padding: 24px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #34CCCD; margin: 0; font-size: 20px;">Nouveau message de contact</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 24px; border: 1px solid #e0e0e0;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
|
||||
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
||||
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></td>
|
||||
</tr>
|
||||
${dto.company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.company}</td></tr>` : ''}
|
||||
${dto.phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.phone}</td></tr>` : ''}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
||||
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
|
||||
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
|
||||
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
||||
<p style="color: #999; font-size: 12px; margin: 0;">Xpeditis — Formulaire de contact</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
await this.emailService.send({
|
||||
to: 'contact@xpeditis.com',
|
||||
replyTo: dto.email,
|
||||
subject: `[Contact] ${subjectLabel} — ${dto.firstName} ${dto.lastName}`,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send contact email: ${error}`);
|
||||
throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer.");
|
||||
}
|
||||
|
||||
return { message: 'Message envoyé avec succès.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password — sends reset email
|
||||
*/
|
||||
|
||||
@ -37,42 +37,6 @@ export class LoginDto {
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export class ContactFormDto {
|
||||
@ApiProperty({ example: 'Jean', description: 'First name' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: 'Dupont', description: 'Last name' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 'jean@acme.com', description: 'Sender email' })
|
||||
@IsEmail({}, { message: 'Invalid email format' })
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Acme Logistics', description: 'Company name' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
company?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+33 6 12 34 56 78', description: 'Phone number' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ example: 'demo', description: 'Subject category' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
subject: string;
|
||||
|
||||
@ApiProperty({ example: 'Bonjour, je souhaite...', description: 'Message body' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty({
|
||||
example: 'john.doe@acme.com',
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { sendContactForm } from '@/lib/api/auth';
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
@ -52,22 +51,11 @@ export default function ContactPage() {
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await sendContactForm({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
company: formData.company || undefined,
|
||||
phone: formData.phone || undefined,
|
||||
subject: formData.subject,
|
||||
message: formData.message,
|
||||
});
|
||||
setIsSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
|
||||
} finally {
|
||||
// Simulate form submission
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
setIsSubmitted(true);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
@ -85,6 +73,7 @@ export default function ContactPage() {
|
||||
title: 'Email',
|
||||
description: 'Envoyez-nous un email',
|
||||
value: 'contact@xpeditis.com',
|
||||
link: 'mailto:contact@xpeditis.com',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
@ -92,6 +81,7 @@ export default function ContactPage() {
|
||||
title: 'Téléphone',
|
||||
description: 'Appelez-nous',
|
||||
value: '+33 1 23 45 67 89',
|
||||
link: 'tel:+33123456789',
|
||||
color: 'from-green-500 to-emerald-500',
|
||||
},
|
||||
{
|
||||
@ -99,13 +89,15 @@ export default function ContactPage() {
|
||||
title: 'Chat en direct',
|
||||
description: 'Discutez avec notre équipe',
|
||||
value: 'Disponible 24/7',
|
||||
link: '#chat',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
},
|
||||
{
|
||||
icon: Headphones,
|
||||
title: 'Support',
|
||||
description: 'Support client',
|
||||
value: 'support@xpeditis.com',
|
||||
description: 'Centre d\'aide',
|
||||
value: 'support.xpeditis.com',
|
||||
link: 'https://support.xpeditis.com',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
},
|
||||
];
|
||||
@ -119,6 +111,22 @@ export default function ContactPage() {
|
||||
email: 'paris@xpeditis.com',
|
||||
isHQ: true,
|
||||
},
|
||||
{
|
||||
city: 'Rotterdam',
|
||||
address: 'Wilhelminakade 123',
|
||||
postalCode: '3072 AP Rotterdam, Netherlands',
|
||||
phone: '+31 10 123 4567',
|
||||
email: 'rotterdam@xpeditis.com',
|
||||
isHQ: false,
|
||||
},
|
||||
{
|
||||
city: 'Hambourg',
|
||||
address: 'Am Sandtorkai 50',
|
||||
postalCode: '20457 Hamburg, Germany',
|
||||
phone: '+49 40 123 4567',
|
||||
email: 'hamburg@xpeditis.com',
|
||||
isHQ: false,
|
||||
},
|
||||
];
|
||||
|
||||
const subjects = [
|
||||
@ -219,20 +227,22 @@ export default function ContactPage() {
|
||||
{contactMethods.map((method, index) => {
|
||||
const IconComponent = method.icon;
|
||||
return (
|
||||
<motion.div
|
||||
<motion.a
|
||||
key={index}
|
||||
href={method.link}
|
||||
variants={itemVariants}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4`}
|
||||
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
|
||||
<p className="text-gray-500 text-sm mb-2">{method.description}</p>
|
||||
<p className="text-brand-turquoise font-medium">{method.value}</p>
|
||||
</motion.div>
|
||||
</motion.a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -436,9 +446,9 @@ export default function ContactPage() {
|
||||
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">Notre bureau</h2>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">Nos bureaux</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Retrouvez-nous à Paris ou contactez-nous par email.
|
||||
Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
@ -677,6 +687,39 @@ export default function ContactPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Map Section */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre présence en Europe</h2>
|
||||
<p className="text-gray-600">
|
||||
Des bureaux stratégiquement situés pour mieux vous servir
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="aspect-[21/9] bg-gradient-to-br from-brand-navy/5 to-brand-turquoise/5 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="w-16 h-16 text-brand-turquoise mx-auto mb-4" />
|
||||
<p className="text-gray-500">Carte interactive bientôt disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
||||
import { getAllBookings, validateBankTransfer } from '@/lib/api/admin';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
@ -32,29 +32,11 @@ export default function AdminBookingsPage() {
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [validatingId, setValidatingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, []);
|
||||
|
||||
const handleDeleteBooking = async (bookingId: string) => {
|
||||
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
|
||||
setDeletingId(bookingId);
|
||||
try {
|
||||
await deleteAdminBooking(bookingId);
|
||||
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erreur lors de la suppression');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateTransfer = async (bookingId: string) => {
|
||||
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
|
||||
setValidatingId(bookingId);
|
||||
@ -304,23 +286,15 @@ export default function AdminBookingsPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||
{booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (openMenuId === booking.id) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
} else {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
||||
setOpenMenuId(booking.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => handleValidateTransfer(booking.id)}
|
||||
disabled={validatingId === booking.id}
|
||||
className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
{validatingId === booking.id ? '...' : '✓ Valider virement'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -329,220 +303,6 @@ export default function AdminBookingsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions Dropdown Menu */}
|
||||
{openMenuId && menuPosition && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[998]"
|
||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||||
/>
|
||||
<div
|
||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
|
||||
>
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const booking = bookings.find(b => b.id === openMenuId);
|
||||
if (booking) {
|
||||
setSelectedBooking(booking);
|
||||
setShowDetailsModal(true);
|
||||
}
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
|
||||
</button>
|
||||
{(() => {
|
||||
const booking = bookings.find(b => b.id === openMenuId);
|
||||
return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
const id = openMenuId;
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
if (id) handleValidateTransfer(id);
|
||||
}}
|
||||
disabled={validatingId === openMenuId}
|
||||
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-700">Valider virement</span>
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
<button
|
||||
onClick={() => {
|
||||
const id = openMenuId;
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
if (id) handleDeleteBooking(id);
|
||||
}}
|
||||
disabled={deletingId === openMenuId}
|
||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||
>
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-600">Supprimer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetailsModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
|
||||
<button
|
||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900">
|
||||
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Statut</label>
|
||||
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
||||
{getStatusLabel(selectedBooking.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Origine</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
|
||||
</div>
|
||||
{selectedBooking.palletCount != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Palettes</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.weightKG != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Poids</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.volumeCBM != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Volume</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedBooking.priceEUR != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} €</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.priceUSD != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">USD</label>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="block text-gray-500">Créée le</label>
|
||||
<div className="mt-1 text-gray-900">
|
||||
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
{selectedBooking.updatedAt && (
|
||||
<div>
|
||||
<label className="block text-gray-500">Mise à jour</label>
|
||||
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedBooking(null);
|
||||
handleValidateTransfer(selectedBooking.id);
|
||||
}}
|
||||
disabled={validatingId === selectedBooking.id}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||
<button
|
||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -81,8 +81,7 @@ export default function AdminCsvRatesPage() {
|
||||
|
||||
{/* Configurations Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Configurations CSV actives</CardTitle>
|
||||
<CardDescription>
|
||||
@ -96,7 +95,6 @@ export default function AdminCsvRatesPage() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
@ -122,7 +120,6 @@ export default function AdminCsvRatesPage() {
|
||||
<TableHead>Taille</TableHead>
|
||||
<TableHead>Lignes</TableHead>
|
||||
<TableHead>Date d'upload</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@ -145,11 +142,6 @@ export default function AdminCsvRatesPage() {
|
||||
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{file.companyEmail ?? '—'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
|
||||
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
|
||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@ -54,9 +54,6 @@ export default function AdminDocumentsPage() {
|
||||
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Helper function to get formatted quote number
|
||||
const getQuoteNumber = (booking: Booking): string => {
|
||||
@ -268,19 +265,6 @@ export default function AdminDocumentsPage() {
|
||||
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
|
||||
if (!window.confirm('Supprimer définitivement ce document ?')) return;
|
||||
setDeletingId(documentId);
|
||||
try {
|
||||
await deleteAdminDocument(bookingId, documentId);
|
||||
setDocuments(prev => prev.filter(d => d.id !== documentId));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erreur lors de la suppression');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, fileName: string) => {
|
||||
try {
|
||||
// Try direct download first
|
||||
@ -442,8 +426,8 @@ export default function AdminDocumentsPage() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Utilisateur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Télécharger
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -484,24 +468,15 @@ export default function AdminDocumentsPage() {
|
||||
{doc.userName || doc.userId.substring(0, 8) + '...'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const menuKey = `${doc.bookingId}::${doc.id}`;
|
||||
if (openMenuId === menuKey) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
} else {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
||||
setOpenMenuId(menuKey);
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document')}
|
||||
className="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -611,60 +586,6 @@ export default function AdminDocumentsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions Dropdown Menu */}
|
||||
{openMenuId && menuPosition && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[998]"
|
||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||||
/>
|
||||
<div
|
||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
|
||||
>
|
||||
<div className="py-2">
|
||||
{(() => {
|
||||
const [bookingId, documentId] = openMenuId.split('::');
|
||||
const doc = documents.find(d => d.bookingId === bookingId && d.id === documentId);
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document');
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Télécharger</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const bId = doc.bookingId;
|
||||
const dId = doc.id;
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
handleDeleteDocument(bId, dId);
|
||||
}}
|
||||
disabled={deletingId === doc.id}
|
||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||
>
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-600">Supprimer</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -144,26 +144,6 @@ export async function validateBankTransfer(bookingId: string): Promise<BookingRe
|
||||
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a booking (admin only)
|
||||
* DELETE /api/v1/admin/bookings/:id
|
||||
* Permanently deletes a booking from the database
|
||||
* Requires: ADMIN role
|
||||
*/
|
||||
export async function deleteAdminBooking(bookingId: string): Promise<void> {
|
||||
return del<void>(`/api/v1/admin/bookings/${bookingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from a booking (admin only)
|
||||
* DELETE /api/v1/admin/bookings/:bookingId/documents/:documentId
|
||||
* Bypasses ownership and status restrictions
|
||||
* Requires: ADMIN role
|
||||
*/
|
||||
export async function deleteAdminDocument(bookingId: string, documentId: string): Promise<void> {
|
||||
return del<void>(`/api/v1/admin/bookings/${bookingId}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
// ==================== DOCUMENTS ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -16,7 +16,6 @@ export interface CsvFileInfo {
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
rowCount?: number;
|
||||
companyEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface CsvFileListResponse {
|
||||
|
||||
@ -71,22 +71,6 @@ export async function getCurrentUser(): Promise<UserPayload> {
|
||||
return get<UserPayload>('/api/v1/auth/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact form — send message to contact@xpeditis.com
|
||||
* POST /api/v1/auth/contact
|
||||
*/
|
||||
export async function sendContactForm(data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/api/v1/auth/contact', data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password — request reset email
|
||||
* POST /api/v1/auth/forgot-password
|
||||
|
||||
@ -24,8 +24,8 @@ export {
|
||||
ApiError,
|
||||
} from './client';
|
||||
|
||||
// Authentication (8 endpoints)
|
||||
export { register, login, refreshToken, logout, getCurrentUser, forgotPassword, resetPassword, sendContactForm } from './auth';
|
||||
// Authentication (7 endpoints)
|
||||
export { register, login, refreshToken, logout, getCurrentUser, forgotPassword, resetPassword } from './auth';
|
||||
|
||||
// Rates (4 endpoints)
|
||||
export { searchRates, searchCsvRates, getAvailableCompanies, getFilterOptions } from './rates';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user