feat(bookings): Phase 2 edition avant paiement + filtre + ergonomie
- Edition d'une reservation avant paiement: page /booking/:id/edit (volume, poids, palettes, notes + gestion documents), endpoint PATCH /csv-bookings/:id/details (owner + PENDING_PAYMENT), methode domaine editDetails. Acces "Modifier" depuis la liste, la modale detail et la page de paiement. - Filtre "A finaliser" (PENDING_PAYMENT + PENDING_BANK_TRANSFER) + libelles et couleurs de tous les statuts. - Tableau des reservations: 6 colonnes + colonne Actions collante a droite (plus de scroll horizontal pour atteindre Payer/Voir). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
19b96517d2
commit
7234343e20
@ -47,6 +47,7 @@ import {
|
|||||||
CsvBookingResponseDto,
|
CsvBookingResponseDto,
|
||||||
CsvBookingListResponseDto,
|
CsvBookingListResponseDto,
|
||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
|
UpdateCsvBookingDetailsDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -560,6 +561,37 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.cancelBooking(id, userId);
|
return await this.csvBookingService.cancelBooking(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update booking cargo details before payment
|
||||||
|
*
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/details
|
||||||
|
*/
|
||||||
|
@Patch(':id/details')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update booking details before payment',
|
||||||
|
description:
|
||||||
|
'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Booking details updated successfully',
|
||||||
|
type: CsvBookingResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
|
async updateBookingDetails(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCsvBookingDetailsDto,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.updateBookingDetails(id, userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add documents to an existing booking
|
* Add documents to an existing booking
|
||||||
*
|
*
|
||||||
|
|||||||
@ -173,6 +173,38 @@ export class CreateCsvBookingDto {
|
|||||||
// Not included in DTO validation but processed separately
|
// Not included in DTO validation but processed separately
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update CSV Booking Details DTO
|
||||||
|
*
|
||||||
|
* Editable cargo characteristics before payment. Carrier, route and price are
|
||||||
|
* derived from the selected rate and cannot be changed here.
|
||||||
|
*/
|
||||||
|
export class UpdateCsvBookingDetailsDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Total volume in cubic meters (CBM)', example: 12.5 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
volumeCBM?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total weight in kilograms', example: 1500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
weightKG?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Number of pallets', example: 5 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
palletCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Additional notes', example: 'Please handle with care' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document DTO for response
|
* Document DTO for response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
CsvBookingDocumentDto,
|
CsvBookingDocumentDto,
|
||||||
CsvBookingListResponseDto,
|
CsvBookingListResponseDto,
|
||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
|
UpdateCsvBookingDetailsDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -1011,6 +1012,47 @@ export class CsvBookingService {
|
|||||||
return this.toResponseDto(updatedBooking);
|
return this.toResponseDto(updatedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the cargo details of a booking before payment (user action).
|
||||||
|
*
|
||||||
|
* Only the owner can edit, and only while the booking is PENDING_PAYMENT.
|
||||||
|
*/
|
||||||
|
async updateBookingDetails(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateCsvBookingDetailsDto
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
this.logger.log(`Updating booking details ${id} by user ${userId}`);
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findById(id);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this booking
|
||||||
|
if (booking.userId !== userId) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the changes (domain logic validates status + values)
|
||||||
|
try {
|
||||||
|
booking.editDetails({
|
||||||
|
volumeCBM: dto.volumeCBM,
|
||||||
|
weightKG: dto.weightKG,
|
||||||
|
palletCount: dto.palletCount,
|
||||||
|
notes: dto.notes,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
|
this.logger.log(`Booking ${id} details updated`);
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bookings for a user (paginated)
|
* Get bookings for a user (paginated)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -67,9 +67,10 @@ export class CsvBooking {
|
|||||||
public readonly carrierEmail: string,
|
public readonly carrierEmail: string,
|
||||||
public readonly origin: PortCode,
|
public readonly origin: PortCode,
|
||||||
public readonly destination: PortCode,
|
public readonly destination: PortCode,
|
||||||
public readonly volumeCBM: number,
|
// Cargo characteristics — editable before payment (see editDetails).
|
||||||
public readonly weightKG: number,
|
public volumeCBM: number,
|
||||||
public readonly palletCount: number,
|
public weightKG: number,
|
||||||
|
public palletCount: number,
|
||||||
public readonly priceUSD: number,
|
public readonly priceUSD: number,
|
||||||
public readonly priceEUR: number,
|
public readonly priceEUR: number,
|
||||||
public readonly primaryCurrency: string,
|
public readonly primaryCurrency: string,
|
||||||
@ -291,6 +292,53 @@ export class CsvBooking {
|
|||||||
this.respondedAt = new Date();
|
this.respondedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit the cargo details of a booking before it is paid.
|
||||||
|
*
|
||||||
|
* Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e.
|
||||||
|
* before it is sent to the carrier. Carrier, route and price derive from the
|
||||||
|
* selected rate and are not editable here.
|
||||||
|
*
|
||||||
|
* @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid
|
||||||
|
*/
|
||||||
|
editDetails(details: {
|
||||||
|
volumeCBM?: number;
|
||||||
|
weightKG?: number;
|
||||||
|
palletCount?: number;
|
||||||
|
notes?: string;
|
||||||
|
}): void {
|
||||||
|
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.volumeCBM !== undefined) {
|
||||||
|
if (details.volumeCBM <= 0) {
|
||||||
|
throw new Error('Volume must be positive');
|
||||||
|
}
|
||||||
|
this.volumeCBM = details.volumeCBM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.weightKG !== undefined) {
|
||||||
|
if (details.weightKG <= 0) {
|
||||||
|
throw new Error('Weight must be positive');
|
||||||
|
}
|
||||||
|
this.weightKG = details.weightKG;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.palletCount !== undefined) {
|
||||||
|
if (details.palletCount < 0) {
|
||||||
|
throw new Error('Pallet count cannot be negative');
|
||||||
|
}
|
||||||
|
this.palletCount = details.palletCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.notes !== undefined) {
|
||||||
|
this.notes = details.notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if booking has expired (7 days without response)
|
* Check if booking has expired (7 days without response)
|
||||||
*
|
*
|
||||||
|
|||||||
416
apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx
Normal file
416
apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Edit Booking Page (before payment)
|
||||||
|
*
|
||||||
|
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
|
||||||
|
* documents of a booking that is still awaiting payment (PENDING_PAYMENT), then
|
||||||
|
* continue to the payment page. Carrier, route and price derive from the selected
|
||||||
|
* rate and are shown read-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
Save,
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Package,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
|
||||||
|
import { upload, del } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface BookingDoc {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
fileName: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingData {
|
||||||
|
id: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
carrierName: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
volumeCBM: number;
|
||||||
|
weightKG: number;
|
||||||
|
palletCount: number;
|
||||||
|
containerType: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
documents: BookingDoc[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditBookingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const bookingId = params.id as string;
|
||||||
|
|
||||||
|
const [booking, setBooking] = useState<BookingData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [volumeCBM, setVolumeCBM] = useState('');
|
||||||
|
const [weightKG, setWeightKG] = useState('');
|
||||||
|
const [palletCount, setPalletCount] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const [uploadingDocs, setUploadingDocs] = useState(false);
|
||||||
|
const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const applyBooking = (data: BookingData) => {
|
||||||
|
setBooking(data);
|
||||||
|
setVolumeCBM(String(data.volumeCBM ?? ''));
|
||||||
|
setWeightKG(String(data.weightKG ?? ''));
|
||||||
|
setPalletCount(String(data.palletCount ?? ''));
|
||||||
|
setNotes(data.notes ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBooking = async () => {
|
||||||
|
try {
|
||||||
|
const data = (await getCsvBooking(bookingId)) as any as BookingData;
|
||||||
|
if (data.status !== 'PENDING_PAYMENT') {
|
||||||
|
// Only bookings awaiting payment can be edited.
|
||||||
|
router.replace('/dashboard/bookings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyBooking(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Impossible de charger la réservation');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bookingId) loadBooking();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bookingId]);
|
||||||
|
|
||||||
|
const persistDetails = async (): Promise<boolean> => {
|
||||||
|
setError(null);
|
||||||
|
const vol = parseFloat(volumeCBM);
|
||||||
|
const wgt = parseFloat(weightKG);
|
||||||
|
const plt = parseInt(palletCount, 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(vol) || vol <= 0) {
|
||||||
|
setError('Le volume doit être un nombre positif.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(wgt) || wgt <= 0) {
|
||||||
|
setError('Le poids doit être un nombre positif.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(plt) || plt < 0) {
|
||||||
|
setError('Le nombre de palettes est invalide.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = (await updateCsvBookingDetails(bookingId, {
|
||||||
|
volumeCBM: vol,
|
||||||
|
weightKG: wgt,
|
||||||
|
palletCount: plt,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
})) as any as BookingData;
|
||||||
|
applyBooking(updated);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const ok = await persistDetails();
|
||||||
|
if (ok) setSuccess('Modifications enregistrées.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinueToPayment = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const ok = await persistDetails();
|
||||||
|
if (ok) router.push(`/dashboard/booking/${bookingId}/pay`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDocuments = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setUploadingDocs(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
Array.from(files).forEach(file => formData.append('documents', file));
|
||||||
|
await upload(`/api/v1/csv-bookings/${bookingId}/documents`, formData);
|
||||||
|
await loadBooking();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur lors de l'ajout des documents");
|
||||||
|
} finally {
|
||||||
|
setUploadingDocs(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = async (docId?: string) => {
|
||||||
|
if (!docId) return;
|
||||||
|
if (!confirm('Supprimer ce document ?')) return;
|
||||||
|
setDeletingDocId(docId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await del(`/api/v1/csv-bookings/${bookingId}/documents/${docId}`);
|
||||||
|
await loadBooking();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression du document');
|
||||||
|
} finally {
|
||||||
|
setDeletingDocId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
||||||
|
<span className="text-gray-600">Chargement...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p className="text-center text-gray-700">{error || 'Réservation introuvable'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/bookings')}
|
||||||
|
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Retour aux réservations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1>
|
||||||
|
<p className="text-gray-500 mb-8">
|
||||||
|
Corrigez les informations de votre réservation avant de procéder au paiement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<p className="text-green-700 text-sm">{success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Read-only rate summary */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Réservation
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Transporteur</p>
|
||||||
|
<p className="font-semibold text-gray-900">{booking.carrierName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Trajet</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{booking.origin} → {booking.destination}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Type</p>
|
||||||
|
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
||||||
|
</div>
|
||||||
|
{booking.bookingNumber && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Numéro</p>
|
||||||
|
<p className="font-semibold text-gray-900">{booking.bookingNumber}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
Le transporteur, le trajet et le prix proviennent du tarif sélectionné et ne sont pas
|
||||||
|
modifiables ici.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable cargo details */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
Caractéristiques de la marchandise
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={volumeCBM}
|
||||||
|
onChange={e => setVolumeCBM(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Poids (kg)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={weightKG}
|
||||||
|
onChange={e => setWeightKG(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Palettes</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={palletCount}
|
||||||
|
onChange={e => setPalletCount(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder="Instructions particulières..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Documents
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadingDocs}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploadingDocs ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => handleAddDocuments(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{booking.documents.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">Aucun document.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{booking.documents.map((doc, idx) => (
|
||||||
|
<li
|
||||||
|
key={doc.id || `${doc.fileName}-${idx}`}
|
||||||
|
className="flex items-center justify-between py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-800 truncate">{doc.fileName}</span>
|
||||||
|
</div>
|
||||||
|
{doc.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteDocument(doc.id)}
|
||||||
|
disabled={deletingDocId === doc.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
{deletingDocId === doc.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 py-3 bg-white border border-gray-300 text-gray-800 rounded-lg font-semibold hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
|
||||||
|
Enregistrer les modifications
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleContinueToPayment}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
Continuer vers le paiement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -223,9 +223,16 @@ export default function PayCommissionPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
||||||
<p className="text-gray-500 mb-8">
|
<p className="text-gray-500 mb-4">
|
||||||
Finalisez votre booking en réglant le forfait par booking
|
Finalisez votre booking en réglant le forfait par booking
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/dashboard/booking/${bookingId}/edit`)}
|
||||||
|
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Modifier la réservation avant de payer
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
||||||
{siretNotVerified && (
|
{siretNotVerified && (
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listCsvBookings } from '@/lib/api';
|
import { listCsvBookings } from '@/lib/api';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Plus, Clock, Eye, X, CreditCard } from 'lucide-react';
|
import { Plus, Clock, Eye, X, CreditCard, Pencil } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
@ -12,6 +12,9 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
|
// Statuses considered "not finalized": booking started but not yet paid.
|
||||||
|
const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER'];
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
export default function BookingsListPage() {
|
||||||
const t = useTranslations('dashboard.bookingsList');
|
const t = useTranslations('dashboard.bookingsList');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@ -50,7 +53,13 @@ export default function BookingsListPage() {
|
|||||||
let filtered = bookings;
|
let filtered = bookings;
|
||||||
|
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
if (statusFilter === 'to_finalize') {
|
||||||
|
filtered = filtered.filter((booking: any) =>
|
||||||
|
TO_FINALIZE_STATUSES.includes(booking.status)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim()) {
|
||||||
@ -101,9 +110,13 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: '', label: t('statusFilter.all') },
|
{ value: '', label: t('statusFilter.all') },
|
||||||
|
{ value: 'to_finalize', label: t('statusFilter.toFinalize') },
|
||||||
|
{ value: 'PENDING_PAYMENT', label: t('status.pendingPayment') },
|
||||||
|
{ value: 'PENDING_BANK_TRANSFER', label: t('status.pendingBankTransfer') },
|
||||||
{ value: 'PENDING', label: t('status.pending') },
|
{ value: 'PENDING', label: t('status.pending') },
|
||||||
{ value: 'ACCEPTED', label: t('status.accepted') },
|
{ value: 'ACCEPTED', label: t('status.accepted') },
|
||||||
{ value: 'REJECTED', label: t('status.rejected') },
|
{ value: 'REJECTED', label: t('status.rejected') },
|
||||||
|
{ value: 'CANCELLED', label: t('status.cancelled') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchTypeOptions: { value: SearchType; label: string }[] = [
|
const searchTypeOptions: { value: SearchType; label: string }[] = [
|
||||||
@ -129,18 +142,24 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||||
|
PENDING_BANK_TRANSFER: 'bg-blue-100 text-blue-800',
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||||
ACCEPTED: 'bg-green-100 text-green-800',
|
ACCEPTED: 'bg-green-100 text-green-800',
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
REJECTED: 'bg-red-100 text-red-800',
|
||||||
|
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||||
};
|
};
|
||||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: t('status.pendingPayment'),
|
||||||
|
PENDING_BANK_TRANSFER: t('status.pendingBankTransfer'),
|
||||||
PENDING: t('status.pending'),
|
PENDING: t('status.pending'),
|
||||||
ACCEPTED: t('status.accepted'),
|
ACCEPTED: t('status.accepted'),
|
||||||
REJECTED: t('status.rejected'),
|
REJECTED: t('status.rejected'),
|
||||||
|
CANCELLED: t('status.cancelled'),
|
||||||
};
|
};
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
@ -352,13 +371,22 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
{booking.status === 'PENDING_PAYMENT' && (
|
||||||
<Link
|
<>
|
||||||
href={`/dashboard/booking/${booking.id}/pay`}
|
<Link
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
href={`/dashboard/booking/${booking.id}/edit`}
|
||||||
>
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
<CreditCard className="h-3.5 w-3.5" />
|
>
|
||||||
{t('actions.pay')}
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Link>
|
{t('actions.edit')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${booking.id}/pay`}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedBooking(booking)}
|
onClick={() => setSelectedBooking(booking)}
|
||||||
@ -378,125 +406,123 @@ export default function BookingsListPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.palletsPackages')}
|
{t('columns.reference')}
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.weight')}
|
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.route')}
|
{t('columns.route')}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('columns.cargo')}
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.status')}
|
{t('columns.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.date')}
|
{t('columns.date')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="sticky right-0 z-10 bg-gray-50 px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.quoteNumber')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.bookingNumber')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.actions')}
|
{t('columns.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{paginatedBookings.map((booking: any) => (
|
{paginatedBookings.map((booking: any) => {
|
||||||
<tr key={`${booking.type}-${booking.id}`} className="hover:bg-gray-50">
|
const reference =
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
booking.type === 'csv'
|
||||||
<div className="text-sm font-medium text-gray-900">
|
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||||||
{booking.type === 'csv'
|
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
? t('units.palletsCount', { count: booking.palletCount })
|
return (
|
||||||
: t('units.containersCount', {
|
<tr key={`${booking.type}-${booking.id}`} className="group hover:bg-gray-50">
|
||||||
count: booking.containers?.length || 0,
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
})}
|
<div className="text-sm font-semibold text-brand-navy">
|
||||||
</div>
|
{booking.bookingNumber || reference}
|
||||||
<div className="text-xs text-gray-500">
|
</div>
|
||||||
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
{booking.bookingNumber && (
|
||||||
</div>
|
<div className="text-xs text-gray-400">{reference}</div>
|
||||||
</td>
|
)}
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</td>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<td className="px-6 py-4">
|
||||||
{booking.type === 'csv'
|
<div className="text-sm text-gray-900">
|
||||||
? t('units.kg', { value: booking.weightKG })
|
{booking.type === 'csv'
|
||||||
: booking.totalWeight
|
? `${booking.origin} → ${booking.destination}`
|
||||||
? t('units.kg', { value: booking.totalWeight })
|
: booking.route || 'N/A'}
|
||||||
: 'N/A'}
|
</div>
|
||||||
</div>
|
<div className="text-sm text-gray-500">
|
||||||
<div className="text-xs text-gray-500">
|
{booking.type === 'csv'
|
||||||
{booking.type === 'csv'
|
? `${booking.carrierName}`
|
||||||
? t('units.cbm', { value: booking.volumeCBM })
|
: booking.carrier || ''}
|
||||||
: booking.totalVolume
|
</div>
|
||||||
? t('units.cbm', { value: booking.totalVolume })
|
</td>
|
||||||
: ''}
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</div>
|
<div className="text-sm text-gray-900">
|
||||||
</td>
|
{booking.type === 'csv'
|
||||||
<td className="px-6 py-4">
|
? t('units.palletsCount', { count: booking.palletCount })
|
||||||
<div className="text-sm text-gray-900">
|
: t('units.containersCount', {
|
||||||
{booking.type === 'csv'
|
count: booking.containers?.length || 0,
|
||||||
? `${booking.origin} → ${booking.destination}`
|
})}
|
||||||
: booking.route || 'N/A'}
|
{' · '}
|
||||||
</div>
|
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
||||||
<div className="text-sm text-gray-500">
|
</div>
|
||||||
{booking.type === 'csv'
|
<div className="text-xs text-gray-500">
|
||||||
? `${booking.carrierName}`
|
{booking.type === 'csv'
|
||||||
: booking.carrier || ''}
|
? `${t('units.kg', { value: booking.weightKG })} · ${t('units.cbm', { value: booking.volumeCBM })}`
|
||||||
</div>
|
: booking.totalWeight
|
||||||
</td>
|
? t('units.kg', { value: booking.totalWeight })
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
: ''}
|
||||||
<span
|
</div>
|
||||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
</td>
|
||||||
booking.status
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
)}`}
|
<span
|
||||||
>
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||||
{getStatusLabel(booking.status)}
|
booking.status
|
||||||
</span>
|
)}`}
|
||||||
</td>
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
{getStatusLabel(booking.status)}
|
||||||
{booking.createdAt || booking.requestedAt
|
</span>
|
||||||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
|
</td>
|
||||||
dateLocale,
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{
|
{booking.createdAt || booking.requestedAt
|
||||||
|
? new Date(
|
||||||
|
booking.createdAt || booking.requestedAt
|
||||||
|
).toLocaleDateString(dateLocale, {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}
|
})
|
||||||
)
|
: 'N/A'}
|
||||||
: 'N/A'}
|
</td>
|
||||||
</td>
|
<td className="sticky right-0 z-10 bg-white group-hover:bg-gray-50 px-6 py-4 whitespace-nowrap text-right shadow-[-8px_0_8px_-8px_rgba(0,0,0,0.08)]">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{booking.type === 'csv'
|
{booking.status === 'PENDING_PAYMENT' && (
|
||||||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
<>
|
||||||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
<Link
|
||||||
</td>
|
href={`/dashboard/booking/${booking.id}/edit`}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
{booking.bookingNumber || '-'}
|
>
|
||||||
</td>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
{t('actions.edit')}
|
||||||
<div className="flex items-center justify-center gap-2">
|
</Link>
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
<Link
|
||||||
<Link
|
href={`/dashboard/booking/${booking.id}/pay`}
|
||||||
href={`/dashboard/booking/${booking.id}/pay`}
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBooking(booking)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<CreditCard className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
{t('actions.pay')}
|
{t('actions.view')}
|
||||||
</Link>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
</td>
|
||||||
onClick={() => setSelectedBooking(booking)}
|
</tr>
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
);
|
||||||
>
|
})}
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
{t('actions.view')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -744,14 +770,24 @@ export default function BookingsListPage() {
|
|||||||
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||||
{t('detail.pendingPaymentNotice')}
|
{t('detail.pendingPaymentNotice')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<div className="mt-3 flex items-center gap-2">
|
||||||
href={`/dashboard/booking/${selectedBooking.id}/pay`}
|
<Link
|
||||||
onClick={() => setSelectedBooking(null)}
|
href={`/dashboard/booking/${selectedBooking.id}/edit`}
|
||||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
|
onClick={() => setSelectedBooking(null)}
|
||||||
>
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
<CreditCard className="h-4 w-4" />
|
>
|
||||||
{t('detail.payNow')}
|
<Pencil className="h-4 w-4" />
|
||||||
</Link>
|
{t('actions.edit')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${selectedBooking.id}/pay`}
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
{t('detail.payNow')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -434,12 +434,16 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"all": "All statuses"
|
"all": "All statuses",
|
||||||
|
"toFinalize": "To finalize"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"pendingPayment": "Payment pending",
|
||||||
|
"pendingBankTransfer": "Transfer pending",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"accepted": "Accepted",
|
"accepted": "Accepted",
|
||||||
"rejected": "Rejected"
|
"rejected": "Rejected",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
},
|
},
|
||||||
"loading": "Loading bookings...",
|
"loading": "Loading bookings...",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@ -447,6 +451,8 @@
|
|||||||
"palletsPackages": "Pallets/Packages",
|
"palletsPackages": "Pallets/Packages",
|
||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"route": "Route",
|
"route": "Route",
|
||||||
|
"cargo": "Cargo",
|
||||||
|
"reference": "Reference",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "Quote No.",
|
"quoteNumber": "Quote No.",
|
||||||
@ -455,7 +461,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"pay": "Pay"
|
"pay": "Pay",
|
||||||
|
"edit": "Edit"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Booking",
|
"title": "Booking",
|
||||||
|
|||||||
@ -434,12 +434,16 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Statut",
|
"label": "Statut",
|
||||||
"all": "Tous les statuts"
|
"all": "Tous les statuts",
|
||||||
|
"toFinalize": "À finaliser"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"pendingPayment": "Paiement en attente",
|
||||||
|
"pendingBankTransfer": "Virement en attente",
|
||||||
"pending": "En attente",
|
"pending": "En attente",
|
||||||
"accepted": "Accepté",
|
"accepted": "Accepté",
|
||||||
"rejected": "Refusé"
|
"rejected": "Refusé",
|
||||||
|
"cancelled": "Annulé"
|
||||||
},
|
},
|
||||||
"loading": "Chargement des réservations...",
|
"loading": "Chargement des réservations...",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
@ -447,6 +451,8 @@
|
|||||||
"palletsPackages": "Palettes/Colis",
|
"palletsPackages": "Palettes/Colis",
|
||||||
"weight": "Poids",
|
"weight": "Poids",
|
||||||
"route": "Route",
|
"route": "Route",
|
||||||
|
"cargo": "Marchandise",
|
||||||
|
"reference": "Référence",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "N° Devis",
|
"quoteNumber": "N° Devis",
|
||||||
@ -455,7 +461,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"view": "Voir",
|
"view": "Voir",
|
||||||
"pay": "Payer"
|
"pay": "Payer",
|
||||||
|
"edit": "Modifier"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Réservation",
|
"title": "Réservation",
|
||||||
|
|||||||
@ -263,6 +263,24 @@ export async function cancelCsvBooking(id: string): Promise<SuccessResponse> {
|
|||||||
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
|
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cargo details of a booking awaiting payment
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/details
|
||||||
|
*/
|
||||||
|
export interface UpdateCsvBookingDetailsRequest {
|
||||||
|
volumeCBM?: number;
|
||||||
|
weightKG?: number;
|
||||||
|
palletCount?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCsvBookingDetails(
|
||||||
|
id: string,
|
||||||
|
data: UpdateCsvBookingDetailsRequest
|
||||||
|
): Promise<CsvBookingResponse> {
|
||||||
|
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a CSV booking (public endpoint, no auth required)
|
* Accept a CSV booking (public endpoint, no auth required)
|
||||||
* POST /api/v1/csv-bookings/:token/accept
|
* POST /api/v1/csv-bookings/:token/accept
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user