fix the upload issue and add driver license upload
This commit is contained in:
@@ -12,6 +12,7 @@ interface CustomerRow {
|
||||
phone: string | null
|
||||
flagged: boolean
|
||||
licenseValidationStatus: string
|
||||
licenseImageUrl: string | null
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
@@ -26,6 +27,8 @@ export default function CustomersPage() {
|
||||
contact: 'Contact',
|
||||
license: 'License',
|
||||
flags: 'Flags',
|
||||
imageUploaded: 'Image uploaded',
|
||||
imageMissing: 'No image',
|
||||
noPhone: 'No phone',
|
||||
flagged: 'Flagged',
|
||||
clear: 'Clear',
|
||||
@@ -38,6 +41,8 @@ export default function CustomersPage() {
|
||||
contact: 'Contact',
|
||||
license: 'Permis',
|
||||
flags: 'Signalements',
|
||||
imageUploaded: 'Image téléversée',
|
||||
imageMissing: 'Pas d’image',
|
||||
noPhone: 'Pas de téléphone',
|
||||
flagged: 'Signalé',
|
||||
clear: 'Aucun risque',
|
||||
@@ -50,6 +55,8 @@ export default function CustomersPage() {
|
||||
contact: 'التواصل',
|
||||
license: 'الرخصة',
|
||||
flags: 'الإشارات',
|
||||
imageUploaded: 'تم رفع الصورة',
|
||||
imageMissing: 'لا توجد صورة',
|
||||
noPhone: 'لا يوجد هاتف',
|
||||
flagged: 'مُعلّم',
|
||||
clear: 'سليم',
|
||||
@@ -91,7 +98,14 @@ export default function CustomersPage() {
|
||||
<p className="text-sm text-slate-700">{row.email}</p>
|
||||
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="badge-gray">{row.licenseValidationStatus}</span>
|
||||
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
|
||||
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ReservationDetail {
|
||||
email: string
|
||||
phone: string | null
|
||||
driverLicense: string | null
|
||||
licenseImageUrl: string | null
|
||||
licenseValidationStatus: string
|
||||
flagged: boolean
|
||||
}
|
||||
@@ -115,6 +116,8 @@ const detailCopy = {
|
||||
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
|
||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||
licenseImageLabel: 'License image',
|
||||
noLicenseImage: 'No license image uploaded.',
|
||||
},
|
||||
fr: {
|
||||
editBooking: 'Modifier la réservation',
|
||||
@@ -150,6 +153,8 @@ const detailCopy = {
|
||||
checkInReadOnly: 'L’inspection de départ est modifiable après confirmation et avant le retour.',
|
||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||
licenseImageLabel: 'Image du permis',
|
||||
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||
},
|
||||
ar: {
|
||||
editBooking: 'تعديل الحجز',
|
||||
@@ -185,6 +190,8 @@ const detailCopy = {
|
||||
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
|
||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||
licenseImageLabel: 'صورة الرخصة',
|
||||
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -436,6 +443,20 @@ export default function ReservationDetailPage() {
|
||||
<p className="text-slate-600">{reservation.customer.email}</p>
|
||||
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
|
||||
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
|
||||
<div className="pt-2">
|
||||
<p className="text-slate-600">{copy.licenseImageLabel}</p>
|
||||
{reservation.customer.licenseImageUrl ? (
|
||||
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
|
||||
<img
|
||||
src={reservation.customer.licenseImageUrl}
|
||||
alt="Driver license"
|
||||
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
|
||||
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
|
||||
|
||||
@@ -11,14 +11,15 @@ type Customer = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
driverLicense?: string | null
|
||||
licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null
|
||||
licenseCountry?: string | null
|
||||
licenseCategory?: string | null
|
||||
licenseImageUrl?: string | null
|
||||
}
|
||||
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
|
||||
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
|
||||
type AdditionalDriverForm = {
|
||||
firstName: BilingualField
|
||||
lastName: BilingualField
|
||||
@@ -65,6 +66,9 @@ export default function NewReservationPage() {
|
||||
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
||||
const [licenseCountry, setLicenseCountry] = useState('')
|
||||
const [licenseCategory, setLicenseCategory] = useState('')
|
||||
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
|
||||
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
|
||||
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
|
||||
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
|
||||
firstName: emptyBilingual(),
|
||||
lastName: emptyBilingual(),
|
||||
@@ -96,6 +100,11 @@ export default function NewReservationPage() {
|
||||
licenseIssuedAt: 'License issued at',
|
||||
licenseCountry: 'License country',
|
||||
licenseCategory: 'License category',
|
||||
licenseImage: 'Driver license image',
|
||||
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
|
||||
licenseImageSelected: 'Selected file',
|
||||
licenseImageCurrent: 'Current image',
|
||||
noLicenseImage: 'No license image uploaded yet.',
|
||||
addAdditionalDriver: 'Add additional driver',
|
||||
additionalDriverInfo: 'Additional driver',
|
||||
dateOfBirth: 'Date of birth',
|
||||
@@ -145,6 +154,11 @@ export default function NewReservationPage() {
|
||||
licenseIssuedAt: 'Permis délivré le',
|
||||
licenseCountry: 'Pays du permis',
|
||||
licenseCategory: 'Catégorie du permis',
|
||||
licenseImage: 'Image du permis',
|
||||
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
|
||||
licenseImageSelected: 'Fichier sélectionné',
|
||||
licenseImageCurrent: 'Image actuelle',
|
||||
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
||||
dateOfBirth: 'Date de naissance',
|
||||
@@ -194,6 +208,11 @@ export default function NewReservationPage() {
|
||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
||||
licenseCountry: 'بلد الرخصة',
|
||||
licenseCategory: 'فئة الرخصة',
|
||||
licenseImage: 'صورة رخصة القيادة',
|
||||
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
||||
licenseImageSelected: 'الملف المحدد',
|
||||
licenseImageCurrent: 'الصورة الحالية',
|
||||
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
|
||||
addAdditionalDriver: 'إضافة سائق إضافي',
|
||||
additionalDriverInfo: 'السائق الإضافي',
|
||||
dateOfBirth: 'تاريخ الميلاد',
|
||||
@@ -261,8 +280,38 @@ export default function NewReservationPage() {
|
||||
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
||||
setLicenseCountry(selected.licenseCountry ?? '')
|
||||
setLicenseCategory(selected.licenseCategory ?? '')
|
||||
setLicenseImageUrl(selected.licenseImageUrl ?? null)
|
||||
setLicenseImageFile(null)
|
||||
}, [customerId, customers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!licenseImageFile) {
|
||||
setLicenseImagePreviewUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(licenseImageFile)
|
||||
setLicenseImagePreviewUrl(objectUrl)
|
||||
return () => URL.revokeObjectURL(objectUrl)
|
||||
}, [licenseImageFile])
|
||||
|
||||
async function uploadLicenseImage(customerIdToUpdate: string) {
|
||||
if (!licenseImageFile) return null
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', licenseImageFile)
|
||||
|
||||
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
|
||||
setLicenseImageUrl(updated.licenseImageUrl ?? null)
|
||||
setLicenseImageFile(null)
|
||||
return updated.licenseImageUrl ?? null
|
||||
}
|
||||
|
||||
async function addCustomer() {
|
||||
setError(null)
|
||||
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
|
||||
@@ -271,7 +320,7 @@ export default function NewReservationPage() {
|
||||
}
|
||||
setSavingCustomer(true)
|
||||
try {
|
||||
const created = await apiFetch<CreatedCustomer>('/customers', {
|
||||
const created = await apiFetch<Customer>('/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: bilingualPrimary(newCustomerFirstName).trim(),
|
||||
@@ -295,6 +344,11 @@ export default function NewReservationPage() {
|
||||
setNewCustomerLastName(emptyBilingual())
|
||||
setNewCustomerEmail('')
|
||||
setNewCustomerPhone('')
|
||||
setLicenseImageUrl(created.licenseImageUrl ?? null)
|
||||
|
||||
if (licenseImageFile) {
|
||||
await uploadLicenseImage(created.id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -332,6 +386,10 @@ export default function NewReservationPage() {
|
||||
}),
|
||||
})
|
||||
|
||||
if (licenseImageFile) {
|
||||
await uploadLicenseImage(customerId)
|
||||
}
|
||||
|
||||
const created = await apiFetch<CreatedReservation>('/reservations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -477,6 +535,32 @@ export default function NewReservationPage() {
|
||||
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
|
||||
{licenseImageFile ? (
|
||||
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
|
||||
) : licenseImageUrl ? (
|
||||
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
|
||||
)}
|
||||
{licenseImagePreviewUrl || licenseImageUrl ? (
|
||||
<img
|
||||
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
|
||||
alt="Driver license preview"
|
||||
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user