make the booking in many steps
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s
This commit is contained in:
@@ -1,808 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
|
||||
|
||||
type Customer = {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
driverLicense?: string | null
|
||||
dateOfBirth?: string | null
|
||||
nationality?: string | null
|
||||
address?: Record<string, unknown> | null
|
||||
licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null
|
||||
licenseCountry?: string | null
|
||||
licenseNumber?: string | null
|
||||
licenseCategory?: string | null
|
||||
licenseImageUrl?: string | null
|
||||
}
|
||||
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
|
||||
type AdditionalDriverForm = {
|
||||
firstName: BilingualField
|
||||
lastName: BilingualField
|
||||
email: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
licenseExpiry: string
|
||||
licenseIssuedAt: string
|
||||
dateOfBirth: string
|
||||
nationality: BilingualField
|
||||
}
|
||||
|
||||
type CreatedReservation = { id: string }
|
||||
import { ReservationWizard } from '@/components/reservations/new/ReservationWizard'
|
||||
import { getReservationWizardCopy } from '@/components/reservations/new/reservationWizard.copy'
|
||||
import type { Customer, Vehicle } from '@/components/reservations/new/reservationWizard.types'
|
||||
|
||||
export default function NewReservationPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const copy = getReservationWizardCopy(language)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [customerId, setCustomerId] = useState('')
|
||||
const [customerSearch, setCustomerSearch] = useState('')
|
||||
const [vehicleId, setVehicleId] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [pickupLocation, setPickupLocation] = useState('')
|
||||
const [returnLocation, setReturnLocation] = useState('')
|
||||
const [depositAmount, setDepositAmount] = useState('0')
|
||||
const [paymentMode, setPaymentMode] = useState('CASH')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [showAddCustomer, setShowAddCustomer] = useState(false)
|
||||
const [savingCustomer, setSavingCustomer] = useState(false)
|
||||
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
|
||||
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
|
||||
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
|
||||
const [newCustomerEmail, setNewCustomerEmail] = useState('')
|
||||
const [newCustomerPhone, setNewCustomerPhone] = useState('')
|
||||
const [driverLicense, setDriverLicense] = useState('')
|
||||
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
|
||||
const [customerNationality, setCustomerNationality] = useState('')
|
||||
const [customerFullAddress, setCustomerFullAddress] = useState('')
|
||||
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
|
||||
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
|
||||
const [licenseExpiry, setLicenseExpiry] = useState('')
|
||||
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(),
|
||||
email: '',
|
||||
phone: '',
|
||||
driverLicense: '',
|
||||
licenseExpiry: '',
|
||||
licenseIssuedAt: '',
|
||||
dateOfBirth: '',
|
||||
nationality: emptyBilingual(),
|
||||
})
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Local Booking',
|
||||
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
|
||||
customer: 'Customer',
|
||||
searchCustomer: 'Search previous customer',
|
||||
vehicle: 'Vehicle',
|
||||
startDate: 'Start date & time',
|
||||
endDate: 'End date & time',
|
||||
pickup: 'Pickup location',
|
||||
return: 'Return location',
|
||||
deposit: 'Deposit amount (MAD)',
|
||||
paymentMode: 'Payment mode',
|
||||
renterIdentity: 'Renter identity',
|
||||
driverLicenseInfo: 'Driver license information',
|
||||
driverLicense: 'Driver license number',
|
||||
licenseExpiry: 'License expiry',
|
||||
licenseIssuedAt: 'License issued at',
|
||||
licenseCountry: 'License country',
|
||||
licenseCategory: 'License category',
|
||||
fullAddress: 'Full address',
|
||||
identityDocumentNumber: 'CIN / Passport number',
|
||||
internationalLicenseNumber: 'International permit number',
|
||||
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',
|
||||
nationality: 'Nationality',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Optional notes…',
|
||||
create: 'Create booking',
|
||||
creating: 'Creating…',
|
||||
cancel: 'Cancel',
|
||||
loadFailed: 'Failed to load customers/vehicles.',
|
||||
invalidDates: 'End date must be after start date.',
|
||||
required: 'Please fill all required fields.',
|
||||
selectCustomer: 'Select customer…',
|
||||
addCustomer: 'Add customer',
|
||||
addCustomerTitle: 'Add new customer',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
saveCustomer: 'Save customer',
|
||||
savingCustomer: 'Saving customer…',
|
||||
selectVehicle: 'Select vehicle…',
|
||||
noVehicles: 'No available vehicles.',
|
||||
paymentModes: {
|
||||
CASH: 'Cash',
|
||||
CARD: 'Card',
|
||||
BANK_TRANSFER: 'Bank transfer',
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Réservation locale',
|
||||
subtitle: 'Créez une réservation directement depuis l’espace, sans réservation en ligne préalable.',
|
||||
customer: 'Client',
|
||||
searchCustomer: 'Rechercher un client existant',
|
||||
vehicle: 'Véhicule',
|
||||
startDate: 'Date et heure de départ',
|
||||
endDate: 'Date et heure de retour',
|
||||
pickup: 'Lieu de départ',
|
||||
return: 'Lieu de retour',
|
||||
deposit: 'Montant du dépôt (MAD)',
|
||||
paymentMode: 'Mode de paiement',
|
||||
renterIdentity: 'Identité du locataire',
|
||||
driverLicenseInfo: 'Informations du permis',
|
||||
driverLicense: 'Numéro du permis',
|
||||
licenseExpiry: 'Expiration du permis',
|
||||
licenseIssuedAt: 'Permis délivré le',
|
||||
licenseCountry: 'Pays du permis',
|
||||
licenseCategory: 'Catégorie du permis',
|
||||
fullAddress: 'Adresse complète',
|
||||
identityDocumentNumber: 'N° CIN / passeport',
|
||||
internationalLicenseNumber: 'N° de permis international',
|
||||
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',
|
||||
nationality: 'Nationalité',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Notes optionnelles…',
|
||||
create: 'Créer la réservation',
|
||||
creating: 'Création…',
|
||||
cancel: 'Annuler',
|
||||
loadFailed: 'Échec du chargement des clients/véhicules.',
|
||||
invalidDates: 'La date de fin doit être après la date de début.',
|
||||
required: 'Veuillez remplir tous les champs requis.',
|
||||
selectCustomer: 'Sélectionner un client…',
|
||||
addCustomer: 'Ajouter un client',
|
||||
addCustomerTitle: 'Ajouter un nouveau client',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
saveCustomer: 'Enregistrer le client',
|
||||
savingCustomer: 'Enregistrement du client…',
|
||||
selectVehicle: 'Sélectionner un véhicule…',
|
||||
noVehicles: 'Aucun véhicule disponible.',
|
||||
paymentModes: {
|
||||
CASH: 'Espèces',
|
||||
CARD: 'Carte',
|
||||
BANK_TRANSFER: 'Virement',
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'حجز محلي',
|
||||
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
|
||||
customer: 'العميل',
|
||||
searchCustomer: 'ابحث عن عميل سابق',
|
||||
vehicle: 'المركبة',
|
||||
startDate: 'تاريخ ووقت البداية',
|
||||
endDate: 'تاريخ ووقت النهاية',
|
||||
pickup: 'موقع الاستلام',
|
||||
return: 'موقع التسليم',
|
||||
deposit: 'مبلغ العربون (MAD)',
|
||||
paymentMode: 'طريقة الدفع',
|
||||
renterIdentity: 'بيانات المستأجر',
|
||||
driverLicenseInfo: 'معلومات رخصة القيادة',
|
||||
driverLicense: 'رقم رخصة القيادة',
|
||||
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
||||
licenseCountry: 'بلد الرخصة',
|
||||
licenseCategory: 'فئة الرخصة',
|
||||
fullAddress: 'العنوان الكامل',
|
||||
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
||||
internationalLicenseNumber: 'رقم الرخصة الدولية',
|
||||
licenseImage: 'صورة رخصة القيادة',
|
||||
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
||||
licenseImageSelected: 'الملف المحدد',
|
||||
licenseImageCurrent: 'الصورة الحالية',
|
||||
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
|
||||
addAdditionalDriver: 'إضافة سائق إضافي',
|
||||
additionalDriverInfo: 'السائق الإضافي',
|
||||
dateOfBirth: 'تاريخ الميلاد',
|
||||
nationality: 'الجنسية',
|
||||
notes: 'ملاحظات',
|
||||
notesPlaceholder: 'ملاحظات اختيارية…',
|
||||
create: 'إنشاء الحجز',
|
||||
creating: 'جارٍ الإنشاء…',
|
||||
cancel: 'إلغاء',
|
||||
loadFailed: 'فشل تحميل العملاء/المركبات.',
|
||||
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
|
||||
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
|
||||
selectCustomer: 'اختر عميلًا…',
|
||||
addCustomer: 'إضافة عميل',
|
||||
addCustomerTitle: 'إضافة عميل جديد',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
saveCustomer: 'حفظ العميل',
|
||||
savingCustomer: 'جارٍ حفظ العميل…',
|
||||
selectVehicle: 'اختر مركبة…',
|
||||
noVehicles: 'لا توجد مركبات متاحة.',
|
||||
paymentModes: {
|
||||
CASH: 'نقدا',
|
||||
CARD: 'بطاقة',
|
||||
BANK_TRANSFER: 'تحويل بنكي',
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
const [customerLoadError, setCustomerLoadError] = useState<string | null>(null)
|
||||
const [vehicleLoadError, setVehicleLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiFetch<Customer[]>('/customers?pageSize=100'),
|
||||
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
|
||||
])
|
||||
.then(([c, v]) => {
|
||||
setCustomers(c ?? [])
|
||||
setVehicles(v ?? [])
|
||||
})
|
||||
.catch((err) => setError(err.message ?? copy.loadFailed))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
let cancelled = false
|
||||
|
||||
const filteredCustomers = (() => {
|
||||
const q = customerSearch.trim().toLowerCase()
|
||||
if (!q) return customers
|
||||
return customers.filter((c) =>
|
||||
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
|
||||
c.email.toLowerCase().includes(q),
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
const [customerResult, vehicleResult] = await Promise.allSettled([
|
||||
apiFetch<Customer[]>('/customers?pageSize=100'),
|
||||
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (customerResult.status === 'fulfilled') {
|
||||
setCustomers(customerResult.value ?? [])
|
||||
setCustomerLoadError(null)
|
||||
} else {
|
||||
setCustomerLoadError(customerResult.reason?.message ?? copy.loadFailed)
|
||||
}
|
||||
|
||||
if (vehicleResult.status === 'fulfilled') {
|
||||
setVehicles(vehicleResult.value ?? [])
|
||||
setVehicleLoadError(null)
|
||||
} else {
|
||||
setVehicleLoadError(vehicleResult.reason?.message ?? copy.loadFailed)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
void loadData()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [copy.loadFailed])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card max-w-3xl p-6">
|
||||
<p className="text-sm text-slate-500">{copy.loading}</p>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
|
||||
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE')
|
||||
|
||||
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
|
||||
|
||||
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
|
||||
const address = customer?.address
|
||||
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
|
||||
const value = address[key]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const selected = customers.find((customer) => customer.id === customerId)
|
||||
if (!selected) return
|
||||
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
|
||||
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
|
||||
setCustomerNationality(selected.nationality ?? '')
|
||||
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
|
||||
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
|
||||
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
|
||||
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
|
||||
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()) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!driverLicense.trim() ||
|
||||
!customerDateOfBirth ||
|
||||
!customerNationality.trim() ||
|
||||
!customerFullAddress.trim() ||
|
||||
!customerIdentityDocumentNumber.trim() ||
|
||||
!licenseExpiry ||
|
||||
!licenseIssuedAt ||
|
||||
!licenseCountry.trim() ||
|
||||
!licenseCategory.trim()
|
||||
) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
setSavingCustomer(true)
|
||||
try {
|
||||
const created = await apiFetch<Customer>('/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: bilingualPrimary(newCustomerFirstName).trim(),
|
||||
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
|
||||
lastName: bilingualPrimary(newCustomerLastName).trim(),
|
||||
lastNameAr: newCustomerLastName.ar.trim() || undefined,
|
||||
email: newCustomerEmail.trim(),
|
||||
phone: newCustomerPhone.trim(),
|
||||
driverLicense: driverLicense.trim(),
|
||||
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
|
||||
nationality: customerNationality.trim(),
|
||||
address: {
|
||||
fullAddress: customerFullAddress.trim(),
|
||||
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
|
||||
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
|
||||
},
|
||||
licenseExpiry: new Date(licenseExpiry).toISOString(),
|
||||
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
|
||||
licenseCountry: licenseCountry.trim(),
|
||||
licenseNumber: driverLicense.trim(),
|
||||
licenseCategory: licenseCategory.trim(),
|
||||
}),
|
||||
})
|
||||
setCustomers((prev) => [created, ...prev])
|
||||
setCustomerId(created.id)
|
||||
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
|
||||
setShowAddCustomer(false)
|
||||
setNewCustomerFirstName(emptyBilingual())
|
||||
setNewCustomerLastName(emptyBilingual())
|
||||
setNewCustomerEmail('')
|
||||
setNewCustomerPhone('')
|
||||
setLicenseImageUrl(created.licenseImageUrl ?? null)
|
||||
|
||||
if (licenseImageFile) {
|
||||
await uploadLicenseImage(created.id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSavingCustomer(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setError(null)
|
||||
if (!canSubmit) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
if (end <= start) {
|
||||
setError(copy.invalidDates)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!driverLicense.trim() ||
|
||||
!customerDateOfBirth ||
|
||||
!customerNationality.trim() ||
|
||||
!customerFullAddress.trim() ||
|
||||
!customerIdentityDocumentNumber.trim() ||
|
||||
!licenseExpiry ||
|
||||
!licenseIssuedAt ||
|
||||
!licenseCountry.trim() ||
|
||||
!licenseCategory.trim()
|
||||
) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/customers/${customerId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
driverLicense: driverLicense.trim(),
|
||||
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
|
||||
nationality: customerNationality.trim(),
|
||||
address: {
|
||||
fullAddress: customerFullAddress.trim(),
|
||||
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
|
||||
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
|
||||
},
|
||||
licenseExpiry: new Date(licenseExpiry).toISOString(),
|
||||
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
|
||||
licenseCountry: licenseCountry.trim(),
|
||||
licenseNumber: driverLicense.trim(),
|
||||
licenseCategory: licenseCategory.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (licenseImageFile) {
|
||||
await uploadLicenseImage(customerId)
|
||||
}
|
||||
|
||||
const created = await apiFetch<CreatedReservation>('/reservations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
customerId,
|
||||
vehicleId,
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
pickupLocation: pickupLocation || undefined,
|
||||
returnLocation: returnLocation || undefined,
|
||||
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
|
||||
paymentMode,
|
||||
additionalDrivers: includeAdditionalDriver ? [{
|
||||
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
|
||||
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
|
||||
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
|
||||
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
|
||||
email: additionalDriver.email.trim() || undefined,
|
||||
phone: additionalDriver.phone.trim() || undefined,
|
||||
driverLicense: additionalDriver.driverLicense.trim(),
|
||||
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
|
||||
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
|
||||
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
|
||||
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
|
||||
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
|
||||
}] : [],
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
})
|
||||
router.push(`/reservations/${created.id}`)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<div className="card p-6 space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-slate-500">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
value={customerSearch}
|
||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||
placeholder={copy.searchCustomer}
|
||||
className="input-field mb-2"
|
||||
/>
|
||||
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="input-field">
|
||||
<option value="">{copy.selectCustomer}</option>
|
||||
{filteredCustomers.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} · {c.email}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-700 hover:underline mt-1"
|
||||
onClick={() => setShowAddCustomer((v) => !v)}
|
||||
>
|
||||
{copy.addCustomer}
|
||||
</button>
|
||||
|
||||
{showAddCustomer ? (
|
||||
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
|
||||
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
|
||||
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
|
||||
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
|
||||
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
|
||||
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
|
||||
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
|
||||
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
|
||||
<option value="">{copy.selectVehicle}</option>
|
||||
{availableVehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.make} {v.model} · {v.licensePlate}</option>
|
||||
))}
|
||||
</select>
|
||||
{vehicles.length === 0 ? (
|
||||
<span className="text-xs text-orange-700">{copy.noVehicles}</span>
|
||||
) : availableVehicles.length === 0 ? (
|
||||
<span className="text-xs text-orange-700">
|
||||
{language === 'fr'
|
||||
? 'Des véhicules existent dans la flotte, mais aucun n’est marqué Disponible.'
|
||||
: language === 'ar'
|
||||
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
|
||||
: 'Vehicles exist in fleet, but none are marked Available.'}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
|
||||
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
|
||||
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
|
||||
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
|
||||
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
|
||||
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
|
||||
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
|
||||
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
|
||||
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
|
||||
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
|
||||
<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">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
|
||||
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
|
||||
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
|
||||
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
|
||||
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
|
||||
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
|
||||
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
|
||||
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
|
||||
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAdditionalDriver}
|
||||
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
|
||||
</label>
|
||||
|
||||
{includeAdditionalDriver ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
|
||||
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
|
||||
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
|
||||
<input
|
||||
value={additionalDriver.email}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
|
||||
<input
|
||||
value={additionalDriver.phone}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
value={additionalDriver.driverLicense}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={additionalDriver.dateOfBirth}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={additionalDriver.licenseIssuedAt}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={additionalDriver.licenseExpiry}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
|
||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
|
||||
</label>
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-3">
|
||||
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
|
||||
{copy.cancel}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
|
||||
{saving ? copy.creating : copy.create}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{customerLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{customerLoadError}</div> : null}
|
||||
{vehicleLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{vehicleLoadError}</div> : null}
|
||||
<ReservationWizard customers={customers} vehicles={vehicles} language={language} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user