fix: resolve payment access, status labels, and document upload reliability

- Add PENDING_PAYMENT and PENDING_BANK_TRANSFER to document statuses
  translations (was displaying raw i18n key instead of human label)
- Fix getStatusLabel/getStatusColor to handle all booking statuses
- Fix select dropdown in Add Document modal to use getStatusLabel()
  instead of hardcoded PENDING/ACCEPTED binary
- Add direct "Payer" button (orange) on PENDING_PAYMENT rows in both
  desktop table and mobile cards, linking to /dashboard/booking/[id]/pay
- Update detail modal to show payment CTA for PENDING_PAYMENT bookings
- Refactor file handling in document modals to use React state (addFiles,
  replaceFile) instead of reading from DOM ref — fixes upload reliability
  and shows selected filename feedback in the dropzone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
David 2026-06-07 17:26:44 +02:00
parent 9950d96fd6
commit b344650608
4 changed files with 113 additions and 64 deletions

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Plus, Clock, Eye, X } from 'lucide-react';
import { Plus, Clock, Eye, X, CreditCard } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
@ -350,6 +350,16 @@ export default function BookingsListPage() {
})
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
<div className="flex items-center gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<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
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"
@ -359,6 +369,7 @@ export default function BookingsListPage() {
</button>
</div>
</div>
</div>
))}
</div>
@ -465,6 +476,16 @@ export default function BookingsListPage() {
{booking.bookingNumber || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<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
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"
@ -472,6 +493,7 @@ export default function BookingsListPage() {
<Eye className="h-3.5 w-3.5" />
{t('actions.view')}
</button>
</div>
</td>
</tr>
))}
@ -719,15 +741,16 @@ export default function BookingsListPage() {
</div>
{selectedBooking.status === 'PENDING_PAYMENT' && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-sm text-amber-700 bg-amber-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')}
</p>
<Link
href="/dashboard/search-advanced"
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
href={`/dashboard/booking/${selectedBooking.id}/pay`}
onClick={() => setSelectedBooking(null)}
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"
>
<Plus className="h-4 w-4" />
{t('detail.newBooking')}
<CreditCard className="h-4 w-4" />
{t('detail.payNow')}
</Link>
</div>
)}

View File

@ -46,12 +46,14 @@ export default function UserDocumentsPage() {
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
const [uploadingFiles, setUploadingFiles] = useState(false);
const [isDraggingAdd, setIsDraggingAdd] = useState(false);
const [addFiles, setAddFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showReplaceModal, setShowReplaceModal] = useState(false);
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
const [replacingFile, setReplacingFile] = useState(false);
const [isDraggingReplace, setIsDraggingReplace] = useState(false);
const [replaceFile, setReplaceFile] = useState<File | null>(null);
const replaceFileInputRef = useRef<HTMLInputElement>(null);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
@ -189,6 +191,8 @@ export default function UserDocumentsPage() {
const getStatusColor = (status: 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',
ACCEPTED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
@ -198,8 +202,15 @@ export default function UserDocumentsPage() {
};
const getStatusLabel = (status: string) => {
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
return t(`statuses.${key}` as any) || status;
const labels: Record<string, string> = {
PENDING_PAYMENT: t('statuses.PENDING_PAYMENT' as any),
PENDING_BANK_TRANSFER: t('statuses.PENDING_BANK_TRANSFER' as any),
PENDING: t('statuses.PENDING' as any),
ACCEPTED: t('statuses.ACCEPTED' as any),
REJECTED: t('statuses.REJECTED' as any),
CANCELLED: t('statuses.CANCELLED' as any),
};
return labels[status] || status;
};
const handleDownload = async (url: string, fileName: string) => {
@ -251,11 +262,12 @@ export default function UserDocumentsPage() {
const handleCloseModal = () => {
setShowAddModal(false);
setSelectedBookingId(null);
setAddFiles([]);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleFileUpload = async () => {
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
if (!selectedBookingId || addFiles.length === 0) {
alert(t('addDocument.noBookingError'));
return;
}
@ -263,10 +275,7 @@ export default function UserDocumentsPage() {
setUploadingFiles(true);
try {
const formData = new FormData();
const files = fileInputRef.current.files;
for (let i = 0; i < files.length; i++) {
formData.append('documents', files[i]);
}
addFiles.forEach(file => formData.append('documents', file));
const token = localStorage.getItem('access_token');
const response = await fetch(
@ -274,7 +283,10 @@ export default function UserDocumentsPage() {
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
);
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || t('addDocument.errorMessage'));
}
alert(t('addDocument.successMessage'));
handleCloseModal();
@ -308,11 +320,12 @@ export default function UserDocumentsPage() {
const handleCloseReplaceModal = () => {
setShowReplaceModal(false);
setDocumentToReplace(null);
setReplaceFile(null);
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
};
const handleReplaceDocument = async () => {
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
if (!documentToReplace || !replaceFile) {
alert(t('replaceDocument.noFileError'));
return;
}
@ -320,7 +333,7 @@ export default function UserDocumentsPage() {
setReplacingFile(true);
try {
const formData = new FormData();
formData.append('document', replaceFileInputRef.current.files[0]);
formData.append('document', replaceFile);
const token = localStorage.getItem('access_token');
const response = await fetch(
@ -768,11 +781,7 @@ export default function UserDocumentsPage() {
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
{bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} (
{booking.status === 'PENDING'
? t('statuses.PENDING')
: t('statuses.ACCEPTED')}
)
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({getStatusLabel(booking.status)})
</option>
))}
</select>
@ -795,11 +804,8 @@ export default function UserDocumentsPage() {
onDrop={e => {
e.preventDefault();
setIsDraggingAdd(false);
if (fileInputRef.current && e.dataTransfer.files.length > 0) {
const dt = new DataTransfer();
Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f));
fileInputRef.current.files = dt.files;
}
const dropped = Array.from(e.dataTransfer.files);
if (dropped.length > 0) setAddFiles(dropped);
}}
onClick={() => fileInputRef.current?.click()}
>
@ -816,11 +822,19 @@ export default function UserDocumentsPage() {
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{addFiles.length > 0 ? (
<p className="text-sm font-medium text-blue-700">
{addFiles.length === 1
? addFiles[0].name
: `${addFiles.length} fichiers sélectionnés`}
</p>
) : (
<p className="text-sm text-gray-600">
{isDraggingAdd
? t('addDocument.dropHere')
: t('addDocument.dragOrClick')}
</p>
)}
<p className="text-xs text-gray-400 mt-1">{t('addDocument.acceptedFormats')}</p>
<input
ref={fileInputRef}
@ -828,9 +842,9 @@ export default function UserDocumentsPage() {
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="hidden"
onChange={() => {
// Force re-render to show selected file names
setIsDraggingAdd(false);
onChange={e => {
const selected = Array.from(e.target.files || []);
if (selected.length > 0) setAddFiles(selected);
}}
/>
</div>
@ -843,7 +857,7 @@ export default function UserDocumentsPage() {
<button
type="button"
onClick={handleFileUpload}
disabled={uploadingFiles || !selectedBookingId}
disabled={uploadingFiles || !selectedBookingId || addFiles.length === 0}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploadingFiles ? (
@ -947,11 +961,8 @@ export default function UserDocumentsPage() {
onDrop={e => {
e.preventDefault();
setIsDraggingReplace(false);
if (replaceFileInputRef.current && e.dataTransfer.files.length > 0) {
const dt = new DataTransfer();
dt.items.add(e.dataTransfer.files[0]);
replaceFileInputRef.current.files = dt.files;
}
const dropped = e.dataTransfer.files[0];
if (dropped) setReplaceFile(dropped);
}}
onClick={() => replaceFileInputRef.current?.click()}
>
@ -968,18 +979,25 @@ export default function UserDocumentsPage() {
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{replaceFile ? (
<p className="text-sm font-medium text-blue-700">{replaceFile.name}</p>
) : (
<p className="text-sm text-gray-600">
{isDraggingReplace
? t('replaceDocument.dropHere')
: t('replaceDocument.dragOrClick')}
</p>
)}
<p className="text-xs text-gray-400 mt-1">{t('replaceDocument.acceptedFormats')}</p>
<input
ref={replaceFileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="hidden"
onChange={() => setIsDraggingReplace(false)}
onChange={e => {
const file = e.target.files?.[0];
if (file) setReplaceFile(file);
}}
/>
</div>
</div>

View File

@ -430,7 +430,8 @@
"actions": "Actions"
},
"actions": {
"view": "View"
"view": "View",
"pay": "Pay"
},
"detail": {
"title": "Booking",
@ -440,7 +441,8 @@
"weight": "Weight / Volume",
"pickupDate": "Requested pickup date",
"price": "Price",
"pendingPaymentNotice": "This booking is awaiting payment. Contact an administrator or create a new booking.",
"pendingPaymentNotice": "This booking is awaiting commission payment. Click below to complete the payment.",
"payNow": "Complete payment",
"newBooking": "New booking",
"close": "Close"
},
@ -1453,6 +1455,8 @@
"actions": "Actions"
},
"statuses": {
"PENDING_PAYMENT": "Payment pending",
"PENDING_BANK_TRANSFER": "Bank transfer pending",
"PENDING": "Pending",
"ACCEPTED": "Accepted",
"REJECTED": "Rejected",

View File

@ -430,7 +430,8 @@
"actions": "Actions"
},
"actions": {
"view": "Voir"
"view": "Voir",
"pay": "Payer"
},
"detail": {
"title": "Réservation",
@ -440,7 +441,8 @@
"weight": "Poids / Volume",
"pickupDate": "Date d'enlèvement souhaitée",
"price": "Prix",
"pendingPaymentNotice": "Cette réservation est en attente de paiement. Contactez l'administrateur ou créez une nouvelle réservation.",
"pendingPaymentNotice": "Cette réservation est en attente du paiement de la commission. Cliquez ci-dessous pour finaliser le paiement.",
"payNow": "Finaliser le paiement",
"newBooking": "Nouvelle réservation",
"close": "Fermer"
},
@ -1453,6 +1455,8 @@
"actions": "Actions"
},
"statuses": {
"PENDING_PAYMENT": "Paiement en attente",
"PENDING_BANK_TRANSFER": "Virement en attente",
"PENDING": "En attente",
"ACCEPTED": "Accepté",
"REJECTED": "Refusé",